Apr 18, 2022

Code hygiene with Elixir - Part 2

In our last post we set up some code hygiene tools and for the most part got everything configured and working the way we want. Today we’ll take a closer look at Boundary.

If you followed along with part 1 you can continue with the code from there. Otherwise you can grab today’s starting off point by:

Terminal
mkdir <some new directory>
cd <the new directory>
Terminal
git init
git remote add origin git@github.com:riebeekn/elixir_code_hygiene.git
git fetch origin 5a7e1ffcbc715b9b8fbc4faf7e3bc7160fb9e185
git reset --hard FETCH_HEAD

You should now see the following git history.

Terminal
git log --pretty=oneline

Now you can install the existing dependencies, and set up the database.

Terminal
mix deps.get
Terminal
mix do ecto.drop, ecto.create, ecto.migrate

Note: I’ve found I occasionally get some weird errors when attempting a git commit after downloading a repo that has GitHooks configured. Removing the hooks (rm -rf ./git/hooks), and then reinstalling them (mix git_hooks.install) seems to clear things up… so I’d try the same if you get some errors when attempting a commit.

Now let’s take a closer look at Boundary.

What does Boundary actually do?

Before we get started with some code, let’s figure out what Boundary actually does. Based on the documentation some examples of what you can do with Boundary:

  • Prevent invocations from the context layer to the web layer.
  • Prevent invocations from the web layer to internal context modules.
  • Prevent usage of Phoenix and Plug in the context layer.
  • Limit usage of Ecto in the web layer to only Ecto.Changeset.
  • Allow :mix modules to be used only at compile time.

So there are a number of things you can do with Boundary, and a lot of it has to do with ensuring we have some structure and rules around how our modules interact with each other.

For our application, we’ll enforce that the Web layer can only call into the backend via the main CodeHygiene module. I like this convention as it makes it very easy to see what your application’s API is; you don’t need to look thru a bunch of context modules. The drawback to this approach is you can get a pretty big main context module. I find this can be somewhat offset by delegating to sub modules.

Another convention we’ll adapt is using a separate top-level schema namespace. This is based on the excellent series of posts by the author of Boundary Saša Jurić. This means instead of the somewhat awkward convention of MyApp.Widgets.Widget, we’ll instead have MyAppSchema.Widget.

So to see this in action, let’s create some code!

Add some code

I typically add things manually versus using scaffolding, but I think in this case scaffolding will be pretty handy. Using scaffolding will also provide an example of what is different between the approach we are taking and the default scaffolding code. So let’s scaffold out a simple Product entity.

Initial scaffold code

Terminal
mix phx.gen.live Products Product products name:string price:integer

As per the output, we’ll add the necessary routes…

/lib/code_hygiene_web/router.ex
scope "/", CodeHygieneWeb do
  pipe_through :browser

  get "/", PageController, :index

  live "/products", ProductLive.Index, :index
  live "/products/new", ProductLive.Index, :new
  live "/products/:id/edit", ProductLive.Index, :edit

  live "/products/:id", ProductLive.Show, :show
  live "/products/:id/show/edit", ProductLive.Show, :edit
end

… and then run migrations.

Terminal
mix ecto.migrate

We’re already seeing a bunch of Boundary errors popping up, we’ll ignore them for now and make sure our new Products page is working.

Terminal
iex -S mix phx.server

If we navigate to http://localhost:4000/products it looks like we’re all good.

So we’re now ready to fix up those pesky warnings.

Fix the warnings

The reason we’re seeing some warnings is we already set up some Boundary rules in our first post and the scaffolding code is violating those rules. Specifically the controller code is calling directly into the Products context module where-as we’ve specified everything needs to go thru the main API module (i.e. code_hygiene.ex).

So the first step is to delegate those calls thru the main API module.

Delegating all front-end calls thru the API

First we’ll add defdelegates to the API module.

/lib/code_hygiene.ex
defmodule CodeHygiene do
  @moduledoc """
  API for the application, all calls from the front-end should go thru
  this module vs directly calling into individual context modules.
  """
  use Boundary

  alias CodeHygiene.Products

  # ===========================================================================
  # Product specific functions
  # ===========================================================================
  defdelegate list_products, to: Products
  defdelegate get_product!(id), to: Products
  defdelegate create_product(attrs \\ %{}), to: Products
  defdelegate update_product(product, attrs), to: Products
  defdelegate delete_product(product), to: Products
  defdelegate change_product(product, attrs \\ %{}), to: Products
end

So now we just need to replace the web calls that are currently running thru CodeHygiene.Product.something() to the API function, i.e. CodeHygiene.something().

There is not much to say about these changes so I’m just going to vomit out the code…

🤮

/lib/code_hygiene_web/live/product_live/form_component.ex
defmodule CodeHygieneWeb.ProductLive.FormComponent do
  @moduledoc false
  use CodeHygieneWeb, :live_component

  @impl true
  def update(%{product: product} = assigns, socket) do
    changeset = CodeHygiene.change_product(product)

    {:ok,
     socket
     |> assign(assigns)
     |> assign(:changeset, changeset)}
  end

  @impl true
  def handle_event("validate", %{"product" => product_params}, socket) do
    changeset =
      socket.assigns.product
      |> CodeHygiene.change_product(product_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :changeset, changeset)}
  end

  def handle_event("save", %{"product" => product_params}, socket) do
    save_product(socket, socket.assigns.action, product_params)
  end

  defp save_product(socket, :edit, product_params) do
    case CodeHygiene.update_product(socket.assigns.product, product_params) do
      {:ok, _product} ->
        {:noreply,
         socket
         |> put_flash(:info, "Product updated successfully")
         |> push_redirect(to: socket.assigns.return_to)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, :changeset, changeset)}
    end
  end

  defp save_product(socket, :new, product_params) do
    case CodeHygiene.create_product(product_params) do
      {:ok, _product} ->
        {:noreply,
         socket
         |> put_flash(:info, "Product created successfully")
         |> push_redirect(to: socket.assigns.return_to)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
end
/lib/code_hygiene_web/live/product_live/index.ex
defmodule CodeHygieneWeb.ProductLive.Index do
  @moduledoc false
  use CodeHygieneWeb, :live_view

  alias CodeHygiene.Products.Product

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, :products, list_products())}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :edit, %{"id" => id}) do
    socket
    |> assign(:page_title, "Edit Product")
    |> assign(:product, CodeHygiene.get_product!(id))
  end

  defp apply_action(socket, :new, _params) do
    socket
    |> assign(:page_title, "New Product")
    |> assign(:product, %Product{})
  end

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Products")
    |> assign(:product, nil)
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    product = CodeHygiene.get_product!(id)
    {:ok, _} = CodeHygiene.delete_product(product)

    {:noreply, assign(socket, :products, list_products())}
  end

  defp list_products do
    CodeHygiene.list_products()
  end
end
/lib/code_hygiene_web/live/product_live/show.ex
defmodule CodeHygieneWeb.ProductLive.Show do
  @moduledoc false
  use CodeHygieneWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  @impl true
  def handle_params(%{"id" => id}, _, socket) do
    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:product, CodeHygiene.get_product!(id))}
  end

  defp page_title(:show), do: "Show Product"
  defp page_title(:edit), do: "Edit Product"
end

The above will take care of almost all the Boundary warnings, we still got the Product schema popping a warning however.

Terminal
mix clean
mix compile --warning-as-errors

To get rid of this we need to set up the schemas as a top level namespace.

Moving the schemas

The final step in getting rid of our warnings is to move the schemas into top level modules.

So we’ll create a top level lib directory for our schemas and move over the existing Product schema file.

Terminal
mkdir lib/code_hygiene_schema
mv lib/code_hygiene/products/product.ex lib/code_hygiene_schema
rmdir lib/code_hygiene/products

The only change we need to make to product.ex is the namespace.

/lib/code_hygiene_schema/product.ex
defmodule CodeHygieneSchema.Product do
  @moduledoc """
  The products schema
  """
  use Ecto.Schema
  import Ecto.Changeset
  ...
  ...

Now we need a single top level schema module similar to how we have code_hygiene.ex and code_hygiene_web.ex that act as top level modules for the backend and web layers. The top level schema module will hold the Boundary rules for the schemas.

Terminal
touch lib/code_hygiene_schema.ex
/lib/code_hygiene_schema.ex
defmodule CodeHygieneSchema do
  @moduledoc """
  Top level schema module
  """
  use Boundary, deps: [], exports: [Product]
end

Here we’re saying the schema module doesn’t depend on anything… and exports the Product schema.

The final bit of code to close out this change is to update any references to the Product schema module to now point to the new namespace, i.e. CodeHygieneSchema.Product instead of CodeHygiene.Products.Product.

/lib/code_hygiene/products.ex
defmodule CodeHygiene.Products do
  @moduledoc """
  The Products context.
  """

  import Ecto.Query, warn: false
  alias CodeHygiene.Repo

  alias CodeHygieneSchema.Product
  ...
/lib/code_hygiene_web/live/product_live/index.ex
defmodule CodeHygieneWeb.ProductLive.Index do
  @moduledoc false
  use CodeHygieneWeb, :live_view

  alias CodeHygieneSchema.Product
  ...
/test/code_hygiene/products_test.exs
defmodule CodeHygiene.ProductsTest do
  use CodeHygiene.DataCase

  alias CodeHygiene.Products

  describe "products" do
    alias CodeHygieneSchema.Product
    ...

With these changes lets see where we are at.

Terminal
mix clean
mix compile --warning-as-errors

Oh no, we’ve gone backwards, even more errors than before!

This is easy enough to fix however, the piece we are missing is to indicate that our API and Web layers have a dependency on the schema layer. We can do this by updating the Boundary statements in code_hygiene.ex and code_hygiene_web.ex.

/lib/code_hygiene.ex
defmodule CodeHygiene do
  @moduledoc """
  API for the application, all calls from the front-end should go thru
  this module vs directly calling into individual context modules.
  """
  use Boundary, deps: [CodeHygieneSchema]
  ...
/lib/code_hygiene_web.ex
defmodule CodeHygieneWeb do
  @moduledoc """
  The entrypoint for defining your web interface, such
  as controllers, views, channels and so on.

  This can be used in your application as:

      use CodeHygieneWeb, :controller
      use CodeHygieneWeb, :view

  The definitions below will be executed for every view,
  controller, etc, so keep them short and clean, focused
  on imports, uses and aliases.

  Do NOT define functions inside the quoted expressions
  below. Instead, define any helper function in modules
  and import those modules here.
  """

  use Boundary, deps: [CodeHygiene, CodeHygieneSchema], exports: [Endpoint]
  ...

Now running mix compile --warnings-as-errors will run clean… sweet!

We’ve successfully enforced some boundaries around our code and for any new sub-context modules we simply need to defdelegate the sub-context calls thru the main API. For any new schemas we add we need to update the exports list in code_hygiene_schema.ex.

I usually add Typespecs for the functions in the API module… so before moving on, let’s do so.

Add Typespecs

First we need to add a type for the Product schema.

/lib/code_hygiene_schema/product.ex
defmodule CodeHygieneSchema.Product do
  @moduledoc """
  The products schema
  """
  use Ecto.Schema
  import Ecto.Changeset

  @type t :: %__MODULE__{
          name: String.t(),
          price: integer()
        }

  schema "products" do
  ...
  ...

And now we can add specs to code_hygiene.ex.

/lib/code_hygiene.ex
defmodule CodeHygiene do
  @moduledoc """
  API for the application, all calls from the front-end should go thru
  this module vs directly calling into individual context modules.
  """
  use Boundary, deps: [CodeHygieneSchema]

  alias CodeHygiene.Products
  alias CodeHygieneSchema.Product

  # ===========================================================================
  # Product specific functions
  # ===========================================================================
  @spec list_products :: list(Product.t())
  defdelegate list_products, to: Products

  @spec get_product!(id :: pos_integer()) :: Product.t() | Ecto.NoResultsError
  defdelegate get_product!(id), to: Products

  @spec create_product(attrs :: map()) :: {:ok, Product.t()} | {:error, Ecto.Changeset.t()}
  defdelegate create_product(attrs \\ %{}), to: Products

  @spec update_product(product :: Product.t(), attrs :: map()) ::
          {:ok, Product.t()} | {:error, Ecto.Changeset.t()}
  defdelegate update_product(product, attrs), to: Products

  @spec delete_product(product :: Product.t()) ::
          {:ok, Product.t()} | {:error, Ecto.Changeset.t()}
  defdelegate delete_product(product), to: Products

  @spec change_product(product :: Product.t(), attrs :: map()) :: Ecto.Changeset.t()
  defdelegate change_product(product, attrs \\ %{}), to: Products
end

Nice!

What about generated code we want to keep as is

As mentionned previously I typically don’t use the scaffold generators so unlike in the last section I would just add new functionality / code that conformed to the Boundary rules right off the bat; but what about a situation where we want to use some code generators and not mess with the generated code? A good example of this would be the mix phx.gen.auth command which adds authentication to a project.

Although we could update the generated code to conform to our Boundary rules like we we did with Products; we might prefer to keep the authentication code as is; doing so will make it easier to apply any updates that might be applied to the Phx.Gen.Auth. Let’s see how we would do this.

We’ll start by running the generator:

Terminal
mix phx.gen.auth Accounts User users

And do as we are told:

Terminal
mix deps.get

Terminal
mix ecto.migrate

Just like when we generated the Product scaffolding we immediately see some Boundary warnings.

It is super simple to get rid of these warnings however, we just need to export the main phx.gen.auth context module (accounts.ex) and the schema (user.ex) in our API module:

/lib/code_hygiene.ex
defmodule CodeHygiene do
  @moduledoc """
  API for the application, all calls from the front-end should go thru
  this module vs directly calling into individual context modules.
  """
  use Boundary, deps: [CodeHygieneSchema], exports: [Accounts, Accounts.User]
Terminal
mix clean
mix compile --warning-as-errors

And we’re all good!

However what happens if we run our tests?

Terminal
mix test

We see we’re getting a warning, this is because the test fixture files are under the core CodeHygiene namespace and thus fall under our Boundary rules for our main context module.

To alleviate this, I like to move the fixtures into their own namespace (CodeHygieneFixtures) and in the fixture modules disable the Boundary checks with use Boundary, check: [in: false, out: false].

Let’s apply these changes to the accounts_fixtures module.

/test/support/fixtures/accounts_fixtures.ex
defmodule CodeHygieneFixtures.AccountsFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `CodeHygiene.Accounts` context.
  """

  use Boundary, check: [in: false, out: false]

The products fixture didn’t pop any Boundary warnings but for continuity we’ll apply the same changes to the products fixture (and any future fixture modules).

/test/support/fixtures/products_fixtures.ex
defmodule CodeHygieneFixtures.ProductsFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `CodeHygiene.Products` context.
  """

  use Boundary, check: [in: false, out: false]

We now have to update all our tests to refer to the new fixture namespace. I’ll show one example:

/test/code_hygiene_web/controllers/user_auth_test.exs
defmodule CodeHygieneWeb.UserAuthTest do
  use CodeHygieneWeb.ConnCase, async: true

  alias CodeHygiene.Accounts
  alias CodeHygieneWeb.UserAuth
  import CodeHygieneFixtures.AccountsFixtures
  ...

The following files need the same treatment, changing any instances of CodeHygiene.AccountsFixtures to CodeHygieneFixtures.AccountsFixtures and any instances of import CodeHygiene.ProductsFixtures to CodeHygieneFixtures.ProductsFixtures:

  • test/code_hygiene/accounts_test.exs
  • test/code_hygiene/products_test.exs
  • test/code_hygiene_web/controllers/user_confirmation_controller_test.exs
  • test/code_hygiene_web/controllers/user_registration_controller_test.exs
  • test/code_hygiene_web/controllers/user_reset_password_controller_test.exs
  • test/code_hygiene_web/controllers/user_session_controller_test.exs
  • test/code_hygiene_web/controllers/user_settings_controller_test.exs
  • test/code_hygiene_web/live/product_live_test.exs

We also need to change the register_and_log_in_user helper in conn_case.ex.

/test/support/conn_case.ex
def register_and_log_in_user(%{conn: conn}) do
  user = CodeHygieneFixtures.AccountsFixtures.user_fixture()
  ...

Now if we run our tests, we won’t get any warnings!

Summary

So that’s it for today. We saw how we can use Boundary to enforce some rules around the way our code interacts with itself.

Next time out we’ll look at the final piece of the puzzle which is ExDoc. We already have ExDoc included as part of our project… but with some customizations we can improve the utility of the documents being generated.

Today’s code

If you want to retrieve the GitHub commit that corresponds to today’s code:

Terminal
mkdir <some new directory>
cd <the new directory>
Terminal
git init
git remote add origin git@github.com:riebeekn/elixir_code_hygiene.git
git fetch origin e29178674c360d9017f49001d5c8a9354ffc5055
git reset --hard FETCH_HEAD

You should now see the following git history.

Terminal
git log --pretty=oneline

Thanks for reading and I hope you enjoyed the post!

References

Saša Jurić’s posts on Building a maintainable Elixir codebase



Comment on this post!