Phoenix contexts aren't finished yet

Phoenix projects are structured like Elixir and any other Elixir project – we split our code into contexts. A context will group related functionality, such as posts and comments, often encapsulating patterns such as data access and data validation. By using contexts, we decouple and isolate our systems into manageable, independent parts. - the Phoenix guides

I recently migrated a Phoenix app from 1.2 to 1.3, including introducing some contexts. I like the concept in general, but I think the current implementation (from the few examples I’ve seen, and from the examples in the official documentation) isn’t quite finished yet. I firmly believe in the stated goal, which I’ve pulled from the documentation above. Decoupling and isolating parts of our system is awesome! But, with contexts as they stand now, that decoupling is only uni-directional.

Our example app

Let’s go with the example in the documentation to keep consistent.

defmodule HelloWeb.UserController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias Hello.Accounts.User

  def index(conn, _params) do
    users = Accounts.list_users()
    render(conn, "index.html", users: users)
  end

  def new(conn, _params) do
    changeset = Accounts.change_user(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case Accounts.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
  ...
end

Now, let’s look at this example a little deeper. The stated goal of these contexts is to keep the parts of our larger system decoupled and independent. And one of the parts of our system is our web layer, so the router, controllers, views and templates.

When I’m thinking of how decoupled two parts of a system are, the heuristic I frequently use is “how easy is it for me to replace one part with another thing”. That frequently takes the form of using some sort of test double or “mock” in testing. If it’s really decoupled, I should be able to test that part using entirely fake data.

Let’s examine that controller a bit more, and specifically let’s look at how we’d test that it. Can we test it without having to insert records into our database? Can we easily switch out the data access layer of our application in order to use fake data?

No, we cannot.

context dependencies

If I want to test a given controller action, I am actually coupled to the implementation of the Accounts module. I cannot test this piece of my system in isolation. Sure, our actual controller isn’t coupled, but the tests for that controller sure are!

We can, however, test our data access and business logic in isolation without any reference to the web layer of our application. This is great! And the more we can break our business logic down into further independent contexts, the better! It’s just not yet true that all of our contexts are equally independent.

Good and Bad dependencies

So, not all dependencies are bad!! Two of the dependencies I’ve highlighted in that picture above are ok - I might even go so far as to say they’re encouraged!

We are all dependent on data in each and every one of our functions. If we expect something to be a String.t and we instead get an integer, then our code will most likely blow up. That’s ok! So, given that this dependency on data is the natural order of things, being very explicit about the data on which we are dependent is great. That’s why I think it’s actually a really great thing that we’re dependent here on specifically the %User{} struct and the %Ecto.Changeset{} struct. We’re clear as to what our functions need.

There are also dependencies on a larger level. If you have a Phoenix application, and you want to use Ecto to manage your data access, you’ll be dependent on that library. That’s again not only ok, but it’s great! Now you don’t need to write your own buggy version of a library to manage database access.

And even the mild form of dependency that we have in our controller is actually pretty darn good! We’re insulated from many different types of implementation details, and only in testing do we really see the extent of the coupling between these contexts. So, what can do we here?

Solutions

We’ve identified that controllers in Phoenix 1.3 are essentially the point of integration between the web layer of your application and any other contexts that you might be using. How can we try and reduce that coupling even further?

Idea 1 - Configuration

The dependency that we really want to remove here is on the Accounts module. So, let’s see if we can do that by configuring our application to use a different module for testing!

We could update our controller to look like this:

defmodule HelloWeb.UserController do
  use HelloWeb, :controller

  @accounts_module Application.get_env(:hello, :accounts_module)

  alias Hello.Accounts.User

  def index(conn, _params) do
    users = @accounts_module.list_users()
    render(conn, "index.html", users: users)
  end

  def new(conn, _params) do
    changeset = @accounts_module.change_user(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}) do
    case @accounts_module.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
  ...
end

We could then add some configuration to config/test.exs

config :hello, :accounts_module, Hello.FakeAccounts

And then in our config/prod.exs and config/dev.exs we could add:

config :hello, :accounts_module, Hello.Accounts

This would allow us to use a fake implementation of that behavior when testing this part of application in isolation. The downside is it would probably end up being a lot of configuration for any application of non-trivial complexity.

Idea 2 - Default arguments

If we want a way to switch out that Accounts module for testing, but we don’t want to spend hours configuring our applications, we could also do that switching by passing that module in as an argument to our controller function. To make it so development and production function just like they always have, we could rely on a default argument.

Let’s update our controller and see what that might look like:

defmodule HelloWeb.UserController do
  use HelloWeb, :controller

  alias Hello.Accounts
  alias Hello.Accounts.User

  def index(conn, _params, accounts_module \\ Accounts) do
    users = accounts_module.list_users()
    render(conn, "index.html", users: users)
  end

  def new(conn, _params, accounts_module \\ Accounts) do
    changeset = accounts_module.change_user(%User{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"user" => user_params}, accounts_module \\ Accounts) do
    case accounts_module.create_user(user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "User created successfully.")
        |> redirect(to: user_path(conn, :show, user))
      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
  ...
end

I think this is preferable to the configuration option, but with one major downside - it means we need to test our controllers in a very specific way. Most controller tests I’ve seen look something like this:

defmodule HelloWeb.UserControllerTest do
  ...
  test "index returns a list of users", %{conn: conn} do
    conn = get(conn, "/users")
    assert conn.status == 200
  end
  ...
end

Nowhere in there are we explicitly calling the function that we would need to call in order to pass in that fake module. We would need to edit that test to look like this:

defmodule HelloWeb.UserControllerTest do
  ...
  test "index returns a list of users", %{conn: conn} do
    conn = HelloWeb.UserController.index(conn, %{}, FakeAccounts)
    assert conn.status == 200
  end
  ...
end

Personally I could argue that this is actually preferable to the established norm, but I generally value following the established norm over doing what I think is right, so I wouldn’t really want to deviate from that here.

Idea 3 - A change in perspective

The whole reason this is a problem for me is because of my understanding of contexts and the documentation around them. In all that documentation, it says that essentially the web layer is a context like any other, and that they should all behave the same way. We’ve seen that this isn’t true, but what if we just accepted the special status of the web layer?

What if, instead of presenting the web layer as just another context, we presented it instead as a sort of superset of all your other contexts, plus some additional behavior? Every other part of your application might be independent and small, but your web layer will depend on those other contexts explicitly, and there will be tight dependencies at that point. If we wanted to pull all the other contexts out into individual applications, we’d be just fine, but we can’t do that with our web layer - at least, not as it stands now.

There’s nothing wrong with that, other than it kind of brings us back to the whole “Phoenix is not your application” argument. Personally, for now, this is the approach I’m going to take. I’d be really interested to see how this idea develops in the future, though!