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_HEADYou 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.getTerminal
mix do ecto.drop, ecto.create, ecto.migrateNote: 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.serverIf 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
endSo 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"
endThe 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/productsThe 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]
endHere 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
endNice!
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-errorsAnd 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.exstest/code_hygiene/products_test.exstest/code_hygiene_web/controllers/user_confirmation_controller_test.exstest/code_hygiene_web/controllers/user_registration_controller_test.exstest/code_hygiene_web/controllers/user_reset_password_controller_test.exstest/code_hygiene_web/controllers/user_session_controller_test.exstest/code_hygiene_web/controllers/user_settings_controller_test.exstest/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_HEADYou should now see the following git history.
Terminal
git log --pretty=oneline
Thanks for reading and I hope you enjoyed the post!