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

  1. In practise, I frequently move common changeset rules to a private base_changeset/2 function, and call that from insert_changeset/2 and update_changeset/2.