A newly-generated Phoenix project has a standard module layout. My Reflective Software co-founder and I have experimented with different module layouts over the years and this is what our projects currently look like:
Core # instead of something like "MyApp"
Core.People # a context module
Core.People.Profile # contains changeset function(s) and an embedded Query module
...
Etc # its sub-modules could be external libraries but are too small for that
Etc.S3Uploader # has functions for uploading to S3
...
Extra # its sub-modules "extend" library functions
Extra.URI # has extra URI-related functions like `Extra.URI.path/1`
...
Schema # contains schemas and structs
Schema.Profile # just the database schema for Profiles; no functions
...
Web # instead of something like "MyAppWeb"
Web.Components # Phoenix components
Web.Components.Nav # a component
...
Web.Controllers # Phoenix controllers
Web.Controllers.Home # a controller
...
Web.Live # Phoenix LiveViews
Web.Live.Profile # a LiveView
...
Web.Plugs # Plugs
endpoint.ex
router.ex
...
Along with this layout, we use the Boundary library to define and enforce boundary separation. (Tip: it’s a lot easier to enforce boundaries if you set them up at the beginning of a project.) A project’s boundaries might look like this:
# Core
deps: [Etc, Extra, Schema]
# Etc
deps: []
# Extra
deps: []
# Schema
deps: []
# Web
deps: [Core, Etc, Extra, Schema]
One obvious difference between this layout and the generated code is that instead of MyApp
and MyAppWeb
, we use Core
and Web
. It’s just as descriptive, a lot shorter, and easier to copy from other projects.
Another difference is that we split “models” into changeset modules and schema modules. A schema module
just defines a schema struct, and is accessible from modules such as Web
.
A changeset module defines changesets and has an internal Query
module containing composable
query parts; it is not accessible from modules like Web
.
defmodule Schema.Profile do
use Schema
@type t() :: %__MODULE__{}
schema "profiles" do
field :name, :string
field :slug, :string
field :totp_secret, :binary, read_after_writes: true
belongs_to :org, Schema.Org
timestamps()
end
end
defmodule Core.People.Profile do
import Ecto.Changeset
@type t() :: Schema.Profile.t()
@required_attrs ~w[name]a
@optional_attrs ~w[org_id]a
def changeset(data \\ %Schema.Profile{}, attrs) do
data
|> cast(Map.new(attrs), @required_attrs ++ @optional_attrs)
|> put_slug()
|> validate_required(@required_attrs)
|> unique_constraint(:slug)
end
defp put_slug(changeset) do
case fetch_field!(changeset, :name) do
nil -> changeset
name -> changeset |> put_change(:slug, Extra.String.to_slug(name, random: 4))
end
end
defmodule Query do
import Ecto.Query
alias Core.People
alias Schema.Profile
def base,
do: from(_ in Profile, as: :profiles) |> default_order()
def default_order(query \\ base()),
do: query |> order_by([profiles: profiles], asc: profiles.name, asc: profiles.seq)
def join_emails(query \\ base()),
do: query |> join(:left, [profiles: profiles], email in assoc(profiles, :emails), as: :emails)
def where_email_address(query \\ base(), email_address),
do: query |> join_emails() |> People.Email.Query.where_address(email_address)
end
end
defmodule Schema.Message do
@type t() :: %__MODULE__{}
defstruct ~w[body subject]a
end
Look for a future blog post about changesets and composable queries.