Computer Things

Phoenix Project Layout

See other articles in the “Elixir” topic.

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:

module structure
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

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:

boundary dependencies in each top-level module
# 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


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
    |> cast(, @required_attrs ++ @optional_attrs)
    |> put_slug()
    |> validate_required(@required_attrs)
    |> unique_constraint(:slug)

  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))

  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:, 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)

Sometimes our schema modules are just plain structs:
defmodule Schema.Message do
  @type t() :: %__MODULE__{}
  defstruct ~w[body subject]a

Look for a future blog post about changesets and composable queries.