Naming Phoenix context functions
In a Phoenix context we frequently need to create and update resources, using changesets. Each operation requires a function to get the changeset, and a function to perform the operation.
If the operation in question is “create”, the standard approach is to name the changeset function create_changeset
, but that’s just confusing; am I getting a changeset for the “create” operation, or am I creating a generic changeset?
Assuming we’re managing a User
resource, I prefer the following conventions.
Creating
Let’s start with the code:
@doc """
Creates a new user with the given attributes.
"""
@spec create_user(map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def create_user(attrs),
do: create_user_changeset(attrs) |> Repo.insert()
@doc """
Returns a changeset for creating a user.
"""
@spec create_user_changeset(map()) :: Changeset.t()
def create_user_changeset(attrs),
do: User.insert_changeset(%User{}, attrs)
The create_user/1
function is responsible for creating a new user. It accepts a map of attributes.
Some people prefer to accept two arguments: a User
struct, and an attributes map. I’ve never found this useful; we’re creating a new resource, so I always end up passing an empty User
struct as the first argument, cluttering up the calling code.
I’ve also seen “create” functions which accept a Changeset
, and simply pass it into Repo.insert/1
. In practise this moves the work of creating the changeset to the calling code (typically a controller or LiveView), instead of keeping it where it belongs.
Speaking of changesets, we need one. The create_user_changeset/1
context function accepts a map of attributes, and returns a changeset for creating a new user.
The final piece of the puzzle is the User
schema function responsible for generating the changeset. We could call this create_changeset/2
, but as noted above that’s ambiguous. I prefer insert_changeset/2
. The function accepts a User
struct or Changeset
as the first argument, and a map of attributes as the second argument.
It typically looks something like this:
@doc """
Returns a changeset for use when creating a user.
"""
@spec insert_changeset(t() | Changeset.t(), map()) :: Changeset.t()
def insert_changeset(struct_or_changeset, attrs) do
valid_attrs = ~w(email name)a
struct_or_changeset
|> cast(attrs, valid_attrs)
|> validate_required(valid_attrs)
end
Updating
The updating functions follow a similar pattern, so I won’t go into as much detail. Once again, we’ll start with the code:
@doc """
Updates the given user.
"""
@spec update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def update_user(%User{} = user, attrs),
do: update_user_changeset(user, attrs) |> Repo.update()
@doc """
Returns a changeset for updating a user.
"""
@spec update_user_changeset(User.t(), map()) :: Changeset.t()
def update_user_changeset(%User{} = user, attrs \\ %{}),
do: User.update_changeset(user, attrs)
The update_user/2
context function is responsible for updating a user. It accepts a User
struct, and a map of changes. We pattern match on the user struct, just to be safe.
The update_user_changeset/2
context function returns a changeset for updating a user. It accepts a User
struct, and an optional map of changes (defaults to an empty map). Once again, we pattern match on the User
struct.
The update_changeset/2
schema function returns a changeset for updating a user. It accepts a User
struct or Changeset
, and a map of changes1:
@doc """
Returns a changeset for use when updating a user.
"""
@spec update_changeset(t() | Changeset.t(), map()) :: Changeset.t()
def update_changeset(struct_or_changeset, attrs) do
valid_attrs = ~w(name)a
struct_or_changeset
|> cast(attrs, valid_attrs)
|> validate_required(valid_attrs)
end
Footnotes
-
In practise, I frequently move common changeset rules to a private
base_changeset/2
function, and call that frominsert_changeset/2
andupdate_changeset/2
. ↩
Sign up for my newsletter
A monthly round-up of blog posts, projects, and internet oddments.