View Source Thinking in Dx
When working with Dx, we have to think top-down, as opposed to bottom-up. We always start from the end result, which we want to achieve. Any logic and inputs needed to get to the end result are defined within Dx.
Example
Say we have a complex requirement to implement:
A user can archive a Todo list, but only if they created it, or if they have an "admin" role, and only if all tasks in the list are completed.
Let's assume we only have the schema, and no other functions, helpers, or rules defined.
defmodule Todo.User do
use Ecto.Schema
use Dx.Ecto.Schema, repo: Todo.Repo
schema "users" do
has_many :roles, Todo.UserRole
end
end
defmodule Todo.UserRole do
use Ecto.Schema
use Dx.Ecto.Schema, repo: Todo.Repo
schema "user_roles" do
field :name, Ecto.Enum, values: [:moderator, :admin, :super_admin]
belongs_to :user, Todo.User
end
end
defmodule Todo.List do
use Ecto.Schema
use Dx.Ecto.Schema, repo: Todo.Repo
schema "lists" do
field :archived_at, :utc_datetime
belongs_to :created_by, Todo.User
end
end
defmodule Todo.Task do
use Ecto.Schema
use Dx.Ecto.Schema, repo: Todo.Repo
schema "tasks" do
field :completed_at, :utc_datetime
belongs_to :list, Todo.List
end
end
The inputs are the list
to be archived and the current_user
who tries to archive it.
The end result is either the now archived list, or an error.
Without Dx, we'd write a function that takes the list
and the user
who tries to archive it.
We'd then have to think about how to load the data needed to compute the result, and implement it.
With Dx, we don't have to think about how to load the data or compute the result. Instead, we focus entirely on what is relevant and write the rules to represent this logic. Dx then takes care of loading data as needed, in an efficient way.
Thinking in Dx, we usually take the following steps:
What are the possible end results that we need to continue in our other code, f.ex. a web request? In our example, the end result can be either "archive the list" or "can't archive list". In code terms, we could return
true
orfalse
, or we could return:ok
or{:error, reason}
.Note: We think of Dx as read-only; we don't perform an action (such as archiving a list), but prepare and compute all the data needed to do it.
What is the primary data point, on which to operate on? In our example, it's rather easy: we operate on a
list
. In other cases, there might be multiple candidates; in these cases, it might help to ask what data type feels most intuitive to return the end results conceived in step 1.Define a predicate on the main data type from step 2 with the possible values from step 1. In the code where the outcome is used, f.ex. a web request, call
Dx
with the main data point and this predicate. We also add additional data needed asargs
.In our example:
# in Todo.List infer archivable?: :ok infer archivable?: {:error, :unauthorized} infer archivable?: {:error, :pending_tasks} # in the List controller with :ok <- Dx.load!(list, :archivable?, args: [current_user: current_user]), {:ok, archived_list} <- List.archive(list) do render(conn, "show.html", list: archived_list) end
Flesh out the conditions for the various cases. For each condition, think about what's needed and how it could be called. If there's a good answer, use the term in the condition as if it already existed. This way, it's easier to stay on the requirements level, using terms that make sense in the app's domain.
In our example, we also reverse the order, checking all error cases first, and returning
:ok
otherwise:# in Todo.List infer archivable?: {:error, :unauthorized}, when: %{can_archive?: false} infer archivable?: {:error, :pending_tasks}, when: %{tasks: %{completed?: false}} infer archivable?: :ok
Define the predicates you used on the correct schema types, and continue the process until the requirements are fully defined using rules.
In our example, the final set of rules might look like this:
# in Todo.List infer archivable?: {:error, :unauthorized}, when: %{can_archive?: false} infer archivable?: {:error, :pending_tasks}, when: %{tasks: %{completed?: false}} infer archivable?: :ok infer can_archive?: true, when: %{args: %{current_user: %{is_admin?: true}}} infer can_archive?: true, when: %{is_owner?: true} infer can_archive?: false infer is_owner?: true, when: %{created_by_id: {:ref, [:args, :current_user, :id]}} infer is_owner?: false # in Todo.User infer is_admin?: true, when: %{roles: %{name: [:admin, :super_admin]}} infer is_admin?: false # in Todo.Task infer completed?: true, when: %{completed_at: {:not, nil}} infer completed?: false
Other use cases covered
We could use the rules we defined to cover other use cases as well:
Filtering archivable lists
Say we have a list of Todo.List
structs, and want to keep only the ones that can be archived,
for example to implement a web request to archive multiple lists. We use Dx.filter/3
for it,
which takes a list of data as well as a condition, just like the ones we use when defining rules:
Dx.filter(lists, %{archivable?: :ok}, args: [current_user: current_user])
Querying all archivable lists
Say we want to query all lists that a given user can archive. We use Dx.query_all/3
for it,
which takes a type as well as a condition, just like the ones we use when defining rules:
Dx.query_all(Todo.List, %{archivable?: :ok}, args: [current_user: current_user])