Computer Things
My Reflective Software business partner and I are currently looking for our next client. Learn more.

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

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.

lib/schema/profile.ex
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

lib/core/people/profile.ex
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

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

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


More “elixir” articles

Creating an RSS feed in Elixir and Phoenix
It's easy to hand-roll an RSS feed using Elixir and Phoenix.
Make Elixir Tests Faster
Tips on making your Elixir tests run faster.
Querying HTML and XML in Elixir with HtmlQuery and XmlQuery
Find and extract information from HTML and XML using Elixir.
Web.Paths
A "Web.Paths" module in your Elixir Phoenix app can be a helpful indirection.
Why Boundary?
About the Boundary library, and why I highly recommend adding it to every Phoenix project from the very beginning.