Rather than extracting shared validation logic to a third module, would it be a better idea to simply expose validation functions as part of the boundary? Let’s just use the User
as an example. Say you have the following (simplistic) schema:
defmodule App.Users.User do
# ... imports and such
schema "users" do
field :name, :string
field :username, :string
end
def validate_name(%Ecto.Changeset{} = changeset, field, options \\ [])
when is_atom(field) and is_list(options) do
changeset
|> validate_required(field)
|> validate_length(field, max: Keyword.get(options, :max_length, 100))
end
def validate_username(%Ecto.Changeset{} = changeset, field, options \\ [])
when is_atom(field) and is_list(options) do
default_format = ~r/\A[a-zA-Z0-9_]+\z/
changeset
|> validate_required(field)
|> validate_length(field, max: Keyword.get(options, :max_length, 32))
|> validate_format(field, Keyword.get(options, :format, default_format)
end
# ... more functions
end
Then inside the App.Users
boundary, you could have something along the lines of the following:
defmodule App.Users do
# ... imports and such
alias App.Users.User
def validate_name(%Ecto.Changeset{} = changeset, field, options \\ [])
when is_atom(field) and is_list(options) do
User.validate_name(changeset, field, options)
end
def validate_username(%Ecto.Changeset{} = changeset, field, options \\ [])
when is_atom(field) and is_list(options) do
User.validate_username(changeset, field, options)
end
# ... more functions
end
I understand in this example I have created a third module with the validations in it, but let’s just assume that the Users
boundary existed to begin with for the base user functionality, such as registration. By exposing these validation functions, you now have the ability to access the “core” user validation without having to duplicate the code. For example, say you had a Support.User
, you could have something along the lines of the following for its changeset function:
defmodule App.Support.User do
# ... imports and such
def changeset(params \\ %{}) when is_map(params) do
%App.Support.User{}
|> cast(params, @required_fields)
|> App.User.validate_username(:username)
# ... more local validation
end
# ... more functions
end
I don’t know whether or not this would be considered good practice or not. What are your thoughts?
I’m still trying to wrap my head around the concept of contexts and how to separate and group different parts of the application. For example, in the case above, I had a Users
context with a User
schema. In my head, it seems like there will be quite a few “pure” contexts like that in a project. The similarity of the two names could get a bit confusing.
I’ve referenced this example in the past on this forum, but I’d like to bring it back up now in the context of contexts. José wrote an article on schemaless queries and embedded schemas when Ecto 2.0 was released. In the example, he had an embedded schema that split data between a Profile
and an Account
.
Now with contexts, I’m trying to think about where the registration would be placed. Would it be in Account
or Profile
? My first thought would be Account
, but then I think that maybe it would just have its own Registration
context. Then it would solely be responsible for the registration and that’s it. It’d be a tiny context with only an embedded schema. It’d likely rely entirely upon other contexts to interact with the repo.
For example, the schema file might look something like this (using the idea from above):
defmodule App.Regisration do
# ... imports and such
embedded_schema do
field :email, :string
field :password, :string
field :name, :string
field :username, :string
end
def changeset(params \\ %{}) when is_map(params) do
%App.Registration{}
|> cast(params, @registration_fields)
|> App.Account.validate_email(:email)
|> App.Account.validate_password(:password)
|> App.Profile.validate_name(:name)
|> App.Profile.validate_username(:username)
end
# ... more functions
end
But in this case, since the Registration
context only does this one task, the context and the schema are in the same file. Is that a bad idea? It seems like there might be a few use cases where that applies. If that’s not something you should be doing, would it be smarter to just have the registration be within the Account
context?
Then there’s the matter of more complex calls to Repo
. Sometimes you have to build multiple large queries and coordinate between contexts to get everything that you might need. If you’re doing that inside of the context files, then I can see the size of the context growing quickly. You could add other files to your context directory that handle each use case. For example, say you have a Profile
call that makes several large queries, you could just split that off into a new file.
lib/
├── profiles/
│ ├─ get_user_profile.ex
│ ├─ profiles.ex
│ ├─ ... more files
Then the call inside your profiles context might look something like:
def get_user_profile(username) when is_binary(username) do
App.Profiles.GetUserProfile.call(username)
end
Then your context will end up being “skinny” with the logic being contained within the relevant file. Again, I don’t know if these are good ideas or not. I’m just trying to get an idea of how this new structure will work. You could end up with your context directory having 20+ files for each use case. Technically, you could take it a step further, and have something like this is the Registration
context:
defmodule App.Registration do
# ... imports and such
def register_account(params \\ %{}) when is_map(params) do
App.Registration.RegisterAccount.call(params)
end
# ... more functions
end
In this case, the register_account.ex
file is being called to handle all the validation and calls to the other contexts/repo. Then there would be no real “logic” within the context file. It would simply be delegating the actions to the correct files, much like a controller. Would that be taking things a step too far?
I know this post is probably a mess, but it’s me just spitballin ideas about the new structure while trying to understand it. I would love to hear thoughts from @josevalim or @chrismccord on this. Thanks to everyone for the help!
Edit: I just thought of this, so maybe I’m slowly starting to learn. Would the Registration
context has it’s own Account
and Profile
schema files that only have the relevent fields for creating each in the database? So maybe something along the lines of:
defmodule App.Registration.Account do
# ... imports and such
schema "accounts" do
field :email, :string
field :password_hash, :string
timestamps()
end
# ... more functions
end
Then maybe there could be a register_account.ex
file that’s the embedded schema that maps to the form from the website. That takes the data and validates it using the methods shown before the edit, but the calls to Repo
use the schemas defined within this context directory rather than called out to a different context (e.g. App.Account.create(data)
).
Then your directory might look something along the lines of:
lib/
├── registration/
│ ├─ account.ex
│ ├─ profile.ex
│ ├─ register_account.ex
│ ├─ registration.ex
│ ├─ ... more files?
And your context file might look something along the lines of:
defmodule App.Registration do
# ... imports and such
def register_account(params \\ %{}) when is_map(params) do
App.Registration.RegisterAccount.call(params)
end
# ... more functions
end
Then inside the register_account.ex
file, you validate the schema, hash the password, start the transaction, and insert all the data into the database. Would the register_account.ex
file be allowed to access App.Registration.Account
or App.Registration.Profile
directly or would it need to talk through its own boundary?
I hope I’m not veering off in the wrong direction at this point.