I find that much of what’s written about using Contexts focuses on abstracting away Ecto Repo operations, but will still end up returning Ecto schemas as results, leaking them into the web layer. In a personal web app I’m working on, I’ve found it helpful to enforce that this doesn’t happen by using embedded schemas at the boundaries of contexts.
I’ve landed on an approach where I will model each context as a RESTful API resource with an embedded schema describing its shape. Each context implements a Resource
behavior that requires callbacks to specify the base query, query filters, and handlers for CRUD operations.
I’d love for others to chime in with how they approach this problem, or if they see it as a problem at all.
Here’s a simplified example of a resource in my app to illustrate what I’m talking about. Being declarative in this way has been helpful for me as resources get more complicated. For example, I find that extending a resource often just requires minimally updating a base_query
and map_query_result
.
defmodule App.Account do
use Ecto.Schema
use App.Resource,
schema: App.Schemas.Account
import Ecto.Query
import Ecto.Changeset
alias __MODULE__
alias App.Repo
alias App.Schemas
embedded_schema do
field :name, :string
field :is_active, :boolean, default: true
field :current_balance, Money.Ecto.Amount.Type
field :available_balance, Money.Ecto.Amount.Type
end
@impl true
def base_query() do
from a in Schemas.Account,
left_join: b in assoc(a, :balance),
preload: [balance: b],
order_by: :name
end
@impl true
def map_query_result(result) do
%Account{
id: result.id,
name: result.name,
is_active: result.is_active,
current_balance: result.balance.current,
available_balance: result.balance.available,
}
end
@impl true
def handle_query_params(args, q) do
case args do
{_, nil} -> q
{:id, val} -> where(q, id: ^val)
{:name, val} -> where(q, name: ^val)
{:is_active, val} -> where(q, is_active: ^val)
end
end
@impl true
def handle_change(account, params, _action) do
account
|> cast(params, [:name, :is_active])
|> validate_required([:name])
end
@impl true
def handle_create(params, changeset) do
# You can put custom logic here to transform the params before
# it's passed to the default handler, which will just update
# the backing schema. You can omit this callback altogether to
# use the default functionality.
super(params, changeset)
end
@impl true
def handle_update(id, params, changeset) do
# Same as above here
super(id, params, changeset)
end
end
And with a use macro, the resource could be used like this:
Account.list(name: ..., is_active: ...)
Account.get(name: ...)
Account.exists?(...)
# returns the new account if valid, a changeset with errors otherwise
Account.create(%{...})
# returns the new account if valid, a changeset with errors otherwise
Account.update(account, %{...})
# useful for forms to preview validations
Account.changeset(account)
It’s definitely not as powerful as Ecto, but I find that this is kind of the point. By hiding the expressivity and power of Ecto behind a structured interface, I’m better able to focus on designing robust, reusable entities for the web layer. My setup has a few holes and limiting assumptions (such as assuming that resources have an id
primary key), but hopefully you get the idea