Using CQRS in Phoenix Contexts
Camilo
Posted on January 12, 2023
Recently I read this article Naming Phoenix context functions. And I think using naming conventions is good and all, but maybe we could make a step futher and apply CQRS inside such modules.
Command Query Responsability Segregation is the notion that you can use a different model to update information than the model you use to read information...
--- Martin Fowler
I made a small system to store gameplays results of dancing machines such a Pump it Up.
For example this is the cards.ex file.
defmodule Rankmode.Cards.Queries do
import Ecto.Query, warn: false
alias Rankmode.Repo
alias Rankmode.Cards.Card
def all() do
Repo.all(Card)
|> Repo.preload([:user, :game, :mix])
end
def for(user: user_id) do
from(c in Card,
where: c.user_id == ^user_id,
order_by: [desc: c.activated_at],
preload: [:user, :game, :mix, :profile])
|> Repo.all()
end
def get!(uid: uid) do
from(c in Card, where: c.uid == ^uid,
preload: [:user, :game, :mix, :profile]
)
|> Repo.one!()
end
def get(uid: uid) do
from(c in Card, where: c.uid == ^uid,
preload: [:user, :game, :mix, :profile]
)
|> Repo.one()
end
def get(id: card_id, user: user_id) do
from(c in Card,
where: c.id == ^card_id and c.user_id == ^user_id,
preload: [:user, :game, :mix, :profile]
)
|> Repo.one()
end
def get(:notactivated, uid: uid) do
from(c in Card,
where: c.uid == ^uid and
is_nil(c.activated_at) and
is_nil(c.user_id),
preload: [:user, :game, :mix]
)
|> Repo.one()
end
end
defmodule Rankmode.Cards.Changesets do
alias Rankmode.Cards.Card
def new(attrs) do
%Card{}
|> Card.changeset(attrs)
end
def empty() do
new(%{})
end
def activate(id, attrs) do
%Card{id: id}
|> Card.changeset_activate(attrs)
end
def update(id, attrs) do
%Card{id: id}
|> Card.changeset(attrs)
end
end
defmodule Rankmode.Cards.Commands do
import Ecto.Query, warn: false
alias Rankmode.Repo
alias Rankmode.Cards.Changesets
def create(attrs) do
Changesets.new(attrs)
|> Repo.insert()
end
def activate(id, attrs) do
Changesets.activate(id, attrs)
|> Repo.update()
end
def update(id, attrs) do
Changesets.update(id, attrs)
|> Repo.update()
end
end
defmodule Rankmode.Cards do
end
And the corresponding Schema file card.ex
defmodule Rankmode.Cards.Card do
use Ecto.Schema
import Ecto.Changeset
schema "cards" do
field :uid, :string
field :checksum, :string
field :activated_at, :naive_datetime
belongs_to :mix, Rankmode.Mixes.Mix
belongs_to :game, Rankmode.Games.Game
belongs_to :user, Rankmode.Accounts.User
has_one :profile, Rankmode.Profiles.Profile
timestamps()
end
@optional [:activated_at, :user_id, :mix_id, :game_id]
@required [:uid, :checksum]
def changeset(model, attrs) do
model
|> cast(transform(attrs), @optional ++ @required)
|> validate_required(@required)
|> validate_length(:uid, min: 3, max: 255)
|> unique_constraint(:uid, name: :cards_uid_checksum_index)
|> unique_constraint(:checksum, name: :cards_uid_checksum_index)
end
def changeset_activate(model, attrs) do
changeset(model, activate(attrs))
end
defp checksum(attrs) do
Map.merge(attrs, %{checksum: Base.encode16(:crypto.hash(:sha256, Map.get(attrs, :uid, "")))})
end
defp activate(attrs) do
Map.merge(attrs, %{activated_at: NaiveDateTime.utc_now()})
end
defp transform(attrs) do
checksum(attrs)
end
end
My approach is separating the concerns in 4 modules.
Base Module
A base module, that could store helper functions or any other function that does not fit in the other modules.
defmodule Rankmode.Cards do
end
Queries Module
A module for mostly SELECT
type functions
defmodule Rankmode.Cards.Queries do
end
Commands Module
A module for mostly INSERT
, UPDATE
, DELETE
type functions.
defmodule Rankmode.Cards.Commands do
end
Changesets Module
A module for storing the changesets used both in Queries and Commands.
defmodule Rankmode.Cards.Changesets do
end
File structure
I prefer storing these in a single file. But if it becomes messy overtime, an structure like this can be used.
cards/
├── card.ex
├── cards.ex
├── changesets.ex
├── commands.ex
└── queries.ex
Posted on January 12, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.