Hi, I was wondering something for the past few weeks while working on multiple projects and I’d like to collect some feedback. Here’s the use case…
Very often, we’ll have the need to store computed fields after some sort of validation has happened. Those fields could be something simple as a user’s full name or something more complex like markdown content that needs to be parsed.
While working on different projects, I always came across the same uncertainty: Where would be the best place to put the computing logic. After thinking carefully about it, I had a different answer for each project I was working on. Here are two similar approaches with conceptual differences:
First example
The context module abstracts all inputs behind the attrs map and everything else is delegated to the schema:
# attrs = %{"name" => "john", "surname" => "doe"}
def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert
end
The schema module takes care of holding the relevant logic and computing the field when the changeset is valid. The changeset validation is coupled to the computing logic (this will be important later):
def changeset(user, attrs) do
user
|> cast([:name, :surname])
|> validate_required([:name, :surname])
|> put_full_name()
end
def put_full_name(changeset) do
if changeset.is_valid? do
name = get_change(changeset, :name)
surname = get_change(changeset, :surname)
put_change(changeset, :surname, name <> surname)
else
changeset
end
end
Second example
In the context, user and “application” input are split. We take care of including the computed value after we have validated user input. The changeset validation is decoupled with the computing logic (this will be important later):
# content = "User x has just logged in!"
# attrs = %{"user_id" => 1 "user_full_name" => "john doe"}
def system_message(attrs, content) do
%Message{}
|> Message.changeset()
|> Message.put_html(html)
|> Repo.insert
end
The schema module only takes care of validating user input and it optionally exposes a way to deal with additional computed values:
def changeset(user, attrs) do
user
|> cast([:user_id, :user_full_name])
|> validate_required([:user_id, :user_full_name])
end
# Depending on how complex or specific this is;
# it could be left inside of the schema or kept in the context
def put_html(changeset, html) do
html = Markdown.parse(content)
put_change(changeset, :html, html)
end
The obvious distinction is that the second example heavily depends on the context function to provide a “valid” way to insert a message while the first does not.
Both approaches have obvious advantages and disadvantages. For instance, if you have to interface with a lot of other modules to have a given value computed (perhaps an external API or service), it would probably be more reasonable to place the logic in the context to not “overload” the schema with too many responsibilities.
Considering that the resulting API is a direct correlation with the level of abstraction that we chose and given that contexts already represent some sort of public API for the application, what approach do you usually prefer in your projects?