Declarative controllers with Phoenix

Joshua Nussbaum - Nov 24 '20 - - Dev Community

The other day I found myself needing to write a bunch of controllers. Maybe I was feeling lazy, but I really wanted to finish the work quickly. I thought about how I could wrap it up in one afternoon.

Maybe I could write a code generator? or some kind of library?

I decided to go the library route.

In the end, it allowed me to write about 10 controllers in a couple of hours, so I'm sharing it.

Anatomy of a controller

A controller action does 3 things:

  1. Input: Passing params and data from conn to a context function.
  2. Algorithm: the context function.
  3. Output: Rendering a response based on the context function's result.

Let's use this create action as an example:

def create(conn, params) do
  # call context function
  case Catalog.create_product(conn.assigns.current_account, params) do
    # pattern match result
    {:ok, product} ->
      # rendering success
      conn
      |> put_status(:created)
      |> render("show.json", product)

    {:error, changeset} ->
      # rendering error
      conn
      |> put_status(:unprocessable_entity)
      |> render("error.json", changeset)
  end
end
Enter fullscreen mode Exit fullscreen mode

In my case, all my controller actions would follow this structure.

Reusable logic

Each controller action is calling a context function and then pattern matching against the result. The result is always a tagged tuple like {:ok, data} or {:error, changeset}. So it seems the response handling could be shared between controllers.

We can extract the shared logic to a module:

defmodule Responder do
  # handle success
  def respond({:ok, record}, conn) do
    conn
    |> put_status(:created)
    |> render("show.json", record: record)
  end

  # handle error
  def respond({:error, changeset}, conn) do
    conn
    |> put_status(:unprocessable_entity)
    |> render("error.json", changeset: changeset)
  end 
end
Enter fullscreen mode Exit fullscreen mode

Then our controller code can be simplified:

# import shared response logic
import Responder

def create(conn, params) do
  # call context
  Catalog.create_product(conn.assigns.current_account, params)
  |> respond(conn) # re-use response logic
end

def show(conn, params) do
  # call context
  Catalog.get_product(conn.assigns.current_account, params["id"])
  |> respond(conn) # re-use response logic
end
Enter fullscreen mode Exit fullscreen mode

Way less repetition, it's starting to shape up.

A pattern emerges

All the actions now have a similar look:

def <action-name>(conn, params) do
  respond(conn, <context-function>.())
end
Enter fullscreen mode Exit fullscreen mode

So why not distill it further with a macro:

defmacro defaction(name, fun) do
  quote do
    def unquote(name)(conn, params) do
      data = %{conn: conn, params: params, assigns: conn.assigns}
      respond(conn, unquote(fun).(data))
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Then, replace def action_name(...) with defaction :action_name, ...:

defaction :index,  &Catalog.list_products(&1.params)
defaction :show,   &Catalog.get_product(&1.params)
defaction :create, &Catalog.create_product(&1.params["product"])
defaction :update, &Catalog.update_product(&1.params["id"], &1.params["product"])
defaction :delete, &Catalog.delete_product(&1.params["id"])
Enter fullscreen mode Exit fullscreen mode

Now our controllers are completely declarative. Easier to read and less typing :)

Hex package

Since it worked out well for me, I open sourced it:

To set it up, install the hex package:

# in mix.exs, add to `deps`:
{:transponder, "~> 0.2"}
Enter fullscreen mode Exit fullscreen mode

Then in any controller, import Transponder, passing the required format (JSON or HTML).

defmodule MyAppWeb.Admin.ProductsController do
  use MyAppWeb, :controller
  use Transponder, format: Transponder.JSON

  defaction :name, &Context.function(...)
end
Enter fullscreen mode Exit fullscreen mode

Conclusion

The idea isn't a new one, it exists in Rails with make_resourceful and resource_controller.

The approach works well for controllers that follow the same pattern. It keeps controller code declarative, increases readability, and eliminates the need for testing controllers.

Of course for more intricate applications, hand-tuned response logic may work better. Nothing wrong with that.

P.S. It's still alpha software, but feel free to give it a try and open PRs

Happy hacking,

. . . . . . . . . . . . . . . . . . .