Intro

For those who don’t know what phoenix contexts are here’s link.

The reason for this approach was a need to use the same model in different contexts. I could have gone with alias MyApp.AnotherContext.MyModel but that didn’t look good.

This approach is a small step towards architecture layer separation. Not like it gets us there, but Rome wasn’t built in a day.

There’ll be more code listings than descriptions.

Top-level

Contexts go to contexts directory, models go to models directory, everything using Ecto.Query goes to models too.

I know that’s too short, so here is explanation a bunch of examples.

Models go to models

That’s the most important one. Everything else is more of a consequence.

By “models” a mean files with schema definitions (Ecto.Schema) and changesets (more on that later):

# lib/my_app/models/pizza.ex

defmodule MyApp.Models.Pizza do
  use Ecto.Schema

  import Ecto.Changeset

  schema "pizzas" do
    field(:name, :string)
    field(:description, :string)
    field(:discounted, :boolean)
    timestamps()
  end

  def changeset(struct, params) do
    struct
    |> cast(attrs, [:name, :description])
    |> validate_required([:name])
  end
end

That move allowes us to extract storage layer (definitions to be used by ORM). And solves the problem of using models in multiple contexts.

Create file for DB queries

As “storage” layer is moved out of contexts, is is moved completely:

# lib/my_app/models/pizza/query.ex

defmodule MyApp.Models.Pizza.Query do
  import Ecto.Query, warn: false
  require Ecto.Query

  def discounted(query) do
    from(q in query, where: discounted == true)
  end

  def non_discounted(query) do
    from(q in query, where: discounted != true)
  end

  def by_id(query) do
    from(q in query, order_by: [desc: q.id])
  end
end

About changesets

Changesets could be put into separate file (or even files) so schema file is not bloated. Like lib/my_app/models/pizza/changesets.ex.

Use that in contexts

Contexts go to contexts folder forming data processing layer (can’t call it “business logic”).

# lib/my_app/contexts/pizzas.ex

defmodule MyApp.Contexts.Pizzas do
  alias MyApp.Models.Pizza
  alias MyApp.Models.Pizza.Query
  alias MyApp.Repo

  def list do
    list_query()
    |> Query.non_discounted()
    |> Repo.all()
  end

  def get!(id) do
    Pizza
    |> Query.non_discounted()
    |> Repo.get!(id)
  end

  def get_by!(clauses) do
    Pizza
    |> Query.non_discounted()
    |> Repo.get_by!(clauses)
  end

  def create(params) do
    changeset = Pizza.changeset(%Pizza{}, params)
    Repo.insert(changeset)
  end

  def update(%Pizza{} = pizza, params) do
    pizza
    |> Pizza.changeset(params)
    |> Repo.update()
  end
end

More on changesets (UPD 2021-10-05)

Apart from schemaless changesets my experience with Ecto tells me of two types of changesets: for storing and for processing.

Storing changesets

These are used to handle DB errors not as exceptions but as changeset errors.

Main usage for these changesets are validate_required for fields which are non-nullable in DB and applying handlers like unique_constraint, check_constraint and foreign_key_constraint.

Should these changesets have casts? That’s an open question for me, because it makes no sense to require fields without filling them in. But combining two types of changesets allows us to move casts out of storing changesets.

There should be only one such changeset per model, accepting only other changesets as an argument (which explicitly tells to put cast somewhere else).

Example:

# lib/my_app/models/pizza.ex

defmodule MyApp.Models.Pizza do
  use Ecto.Schema

  alias Ecto.Changeset
  import Ecto.Changeset

  schema "pizzas" do
    field(:name, :string)
    field(:description, :string)
    field(:discounted, :boolean)
    timestamps()
  end

  def db_changeset(%Changeset{} = changeset) do
    changeset
    |> validate_required([:name])
    |> unique_constraint(:name, name: :unique_name_index)
  end
end

Processing changesets

That’s where it gets interesting.

These changesets should go to contexts that use them and go before (and with) storing changesets.

For example we have to set :name and :description at one place but only :description at another:

defmodule MyApp.Contexts.Admin.Pizzas do
  alias Ecto.Changeset
  alias MyApp.Models.Pizza
  alias MyApp.Repo

  def update(%Pizza{} = pizza, params) do
    pizza
    |> changeset(params)
    |> Repo.update()
  end

  def changeset(%Pizza{} = pizza, params) do
    pizza
    |> Changeset.cast(params, [:name, :description])
    |> Pizza.db_changeset()
  end
end
defmodule MyApp.Contexts.Manager.Pizzas do
  alias Ecto.Changeset
  alias MyApp.Models.Pizza
  alias MyApp.Repo

  def update(%Pizza{} = pizza, params) do
    pizza
    |> changeset(params)
    |> Repo.update()
  end

  def changeset(%Pizza{} = pizza, params) do
    pizza
    |> Changeset.cast(params, [:description])
    |> Changeset.validate_required([:description])
    |> Pizza.db_changeset()
  end
end

So instead of bloating storage layer with processing definitions like def admin_changeset(struct, params) and def manager_changeset(struct, params) we put ‘em to user story definition that uses that exact changeset.

That does not look very good

Because we use the same word “changeset” (and even implementation under the hood) for two different things: DB handling and user input validation and normalization.

The only thing I can think of to make it better is to name these changesets so it would be clear which changeset is for what.

Gateways (UPD 2021-10-10)

More on project structure!

Some systems have to communicate with other systems (HTTP, MQ, etc).

That “communications” layer has to be separated from everything else (IMHO). I usually go with “lib/my_app/gateways” folder.

Example:

# lib/my_app/gateways/some_other_service.ex

defmodule MyApp.Gateways.SomeOtherService do
  @items_url "http://example.com/items"

  def get_items do
    request = {@items_url, []}

    with {:ok, { {_, 200, _}, _, raw_response}} <- :httpc.request(:get, request, [], []),
         {:ok, response} <- Jason.decode(raw_response) do
      response["items"]
    end
  end
end
# lib/my_app/contexts/items.ex

defmodule MyApp.Contexts.Items do
  alias MyApp.Gateways.SomeOtherService, as: SomeOtherServiceGateway

  def load_items do
    case SomeOtherServiceGateway.get_items() do
      {:ok, items} -> save_items(items)
      error -> error
    end
  end

  def save_items do
    # save items to DB
  end
end

Dependency inversion

Sort of.

More often than not it’s impossible to reach other service from local machine, so some kind of fake service implementation has to be used. In that case instead of using gateway modules one can just get preconfigured module by calling a function like this:

# config/dev.exs
# ...
config :my_app, :use_fake_other_service, true
# ...
# lib/my_app/gateways.ex

defmodule MyApp.Gateways do
  alias MyApp.Gateways.OtherService
  alias MyApp.Gateways.OtherService.Fake, as: FakeOtherService

  def other_service do
    if Application.get_env(:my_app, :use_fake_other_service) do
      FakeOtherService
    else
      OtherService
    end
  end
end
# lib/my_app/gateways/other_service/fake.ex

defmodule MyApp.Gateways.OtherService.Fake do
  def get_items, do: {:ok, [1, 2, 3, 4, 5]}
end
# lib/my_app/contexts/items.ex

defmodule MyApp.Contexts.Items do
  alias MyApp.Gateways

  def load_items do
    case Gateways.other_service().get_items() do
      {:ok, items} -> save_items(items)
      error -> error
    end
  end

  def save_items do
    # save items to DB
  end
end