The Domain Resource Action is an architecture pattern where each module for the Business Logic layer represents a single action possible on a Resource of a Domain or SubDomain of a Domain. This means that each module for a Resource Action is responsible for only one action, therefore it can only contain one public function, and when necessary its bang version, the function name ending with !, e.g. read/1 and read!/1.
All modules inside a Resource Action can only be accessed through the Resource API module from the web layer (LiveViews, Controllers, etc.), from other Domain Resources or from the same Resource. This reduces accidental complexity by avoiding direct coupling between modules across boundaries. This also makes it very easy to refactor the code later because anything consuming the Business Logic is only aware of the API module for the Resource.
Each Resource Action is unit tested and the Resource API is only tested via its doctests to ensure docs examples are in sync with the code and that each function can be invoked.
Any project following this DOMAIN_RESOURCE_ACTION_ARCHITECTURE.md MUST strictly adhere to 1. Folder Structure and implement 2. Milliseconds Timestamps and 3. Binary IDs without making assumptions, when in doubt always ask the user for clarification.
The below folder structure only contains the minimal number of Elixir files required to illustrate the Domain Resource Action pattern:
lib
├── my_app
│ ├── catalogs
│ │ ├── categories
│ │ │ ├── subscribe
│ │ │ │ └── subscribe_catalog_category.ex
│ │ │ ├── broadcast
│ │ │ │ └── broadcast_catalog_category.ex
│ │ │ ├── list
│ │ │ │ └── list_catalog_categories.ex
│ │ │ ├── get
│ │ │ │ └── get_catalog_category.ex
│ │ │ ├── create
│ │ │ │ └── create_catalog_category.ex
│ │ │ ├── update
│ │ │ │ └── update_catalog_category.ex
│ │ │ ├── delete
│ │ │ │ └── read_catalog_category.ex
│ │ │ ├── change
│ │ │ │ └── change_catalog_category.ex
│ │ │ └── category.ex
│ │ ├── products
│ │ │ ├── subscribe
│ │ │ │ └── subscribe_catalog_product.ex
│ │ │ ├── broadcast
│ │ │ │ └── broadcast_catalog_product.ex
│ │ │ ├── list
│ │ │ │ └── list_catalog_categories.ex
│ │ │ ├── get
│ │ │ │ └── get_catalog_product.ex
│ │ │ ├── create
│ │ │ │ └── create_catalog_product.ex
│ │ │ ├── update
│ │ │ │ └── update_catalog_product.ex
│ │ │ ├── delete
│ │ │ │ └── read_catalog_product.ex
│ │ │ ├── change
│ │ │ │ └── change_catalog_product.ex
│ │ │ └── product.ex
│ │ ├── shared_resources
│ │ │ ├── bulk_create
│ │ │ ├── bulk_update
│ │ │ ├── bulk_delete
│ │ │ └── whatever_else
│ │ ├── catalogs_categories_api.ex
│ │ ├── catalogs_products_api.ex
│ │ └── shared_resources_api.ex
│ ├── shared_domains
│ │ ├── bulk_create
│ │ ├── bulk_update
│ │ ├── bulk_delete
│ │ └── whatever_else
│ ├── warehouses
│ │ ├── stocks
│ │ │ ├── subscribe
│ │ │ │ └── subscribe_warehouse_stock.ex
│ │ │ ├── broadcast
│ │ │ │ └── broadcast_warehouse_stock.ex
│ │ │ ├── list
│ │ │ │ └── list_warehouses_stocks.ex
│ │ │ ├── get
│ │ │ │ └── get_warehouse_stock.ex
│ │ │ ├── create
│ │ │ │ └── create_warehouse_stock.ex
│ │ │ ├── update
│ │ │ │ └── update_warehouse_stock.ex
│ │ │ ├── delete
│ │ │ │ └── read_warehouse_stock.ex
│ │ │ ├── change
│ │ │ │ └── change_warehouse_stock.ex
│ │ │ └── stock.ex
│ │ └── warehouses_stocks_api.ex
│ └── shared_domains_api.ex
├── my_app_web
│ ├── live
│ │ ├── catalogs
│ │ │ ├── categories
│ │ │ │ ├── category_live
│ │ │ │ │ ├── form.ex
│ │ │ │ │ ├── index.ex
│ │ │ │ │ └── show.ex
│ │ │ ├── products
│ │ │ │ ├── product_live
│ │ │ │ │ ├── form.ex
│ │ │ │ │ ├── index.ex
│ │ │ │ │ └── show.ex
│ │ ├── warehouse
│ │ │ ├── stocks
│ │ │ │ ├── stock_live
│ │ │ │ │ ├── form.ex
│ │ │ │ │ ├── index.ex
│ │ │ │ │ └── show.ex
...
Breaking down the partial folder structure example for an Online Shop:
- Domains:
catalogs,warehouses - Resources:
categories,products,stocks - Shared Folders:
shared_resources,shared_domains - Actions:
subscribe,broadcast,list,get,create,update,delete,change,bulk_create,bulk_update,bulk_delete - Resource APIs:
catalogs_categories_api.ex,catalogs_products_api.ex,warehouses_stocks_api.ex,shared_resources_api.ex,shared_domains_api.ex - Ecto Schemas:
category.ex,product.ex,stock.ex
NOTE: Both Domains and Resources may have a shared folder for actions that are shared or for other things that are common to them. Shared folders don't have a schema by default, but they MUST always have the API module.
As per the folder structure:
- Directory:
lib/my_app/<domain_plural>/ - File:
<domain_plural>_<resource_plural>_api.ex(e.g.,lib/my_app/catalogs/catalogs_products_api.ex) - Module:
MyApp.<DomainPlural>.<DomainPlural><ResourcePlural>API(e.g.,MyApp.Catalogs.CatalogsProductsAPI). Catalogs is the Domain, Products the Resource, and API the Type of module.
As per the folder structure:
- Directory:
lib/my_app/<domain_plural>/<resource_plural>/ - File:
<resource_singular>.ex(e.g.,lib/my_app/catalogs/products/product.ex) - Module:
MyApp.<DomainPlural>.<ResourcePlural>.<ResourceSingular>(e.g.,MyApp.Catalogs.Products.Product).
As per the folder structure:
- Directory:
lib/my_app/<domain_plural>/<resource_plural>/<action_singular>/ - File:
<action_singular>_<domain_singular>_<resource_singular>.ex(e.g.,lib/my_app/catalogs/products/update/update_catalog_product.ex) - Module:
MyApp.<DomainPlural>.<ResourcePlural>.<ActionSingular>.<ActionSingular><DomainSingular><ResourceSingular>(e.g.,MyApp.Catalogs.Products.Update.UpdateCatalogProduct).
The Module Types listed here are only the ones introduced by using the Domain Resource Action architecture pattern, not a list of all that can be found in an Elixir Phoenix project.
The Domain folder is located at lib/my_app/<domain_plural>, e.g. lib/my_app/catalogs.
Module types:
API- These files are the only way that Resources can be accessed, including from inside the Resource itself. They define the public contract for each Resource on the Domain folder.
The Domain Resource folder is located at lib/my_app/<domain_plural>/<resource_plural>, e.g. lib/my_app/catalogs/products:
Module types:
Ecto Schema- These are the usual Ecto Schema module generated by the Phoenix code generators, e.g,product.ex.
The Domain Resource Action folder is located at
Module Types:
- For simple actions, like the ones generated by Phoenix code generators, a single module will suffice. For example:
update_category_product.ex. - For complex actions it may be wise to separate them into at least three modules:
Handler- entrypoint module to coordinate the work being done.Core- for pure business logic without side effects.Storage,Queues, etc. - a dedicated module per type of communication with the external world.
Example for the file catalogs_products_api.ex in the folder structure example.
# lib/my_app/catalogs/catalogs_products_api.ex
defmodule MyApp.Catalogs.CatalogProductApi do
@moduledoc """
The Product API for the Catalogs.
"""
alias MyApp.Catalogs.Products.Update.UpdateCatalogProduct
# Not using defdelegate because we will lose the API contract
@doc """
Updates a Product in the Catalogs.
## Examples
iex> update_catalog_product(scope, current_product, %{field: new_value})
{:ok, %CatalogProduct{}}
iex> update_catalog_product(scope, current_product, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_catalog_product(%Scope{} = scope, %CatalogProduct{} = current_product, %{} = attrs) do
# To make the API contract explicit we may want to use a case statement to check the expected return type.
UpdateCatalogProduct.update(scope, current_product, attrs)
end
endFor simple actions, like the ones generated by Phoenix code generators, a single module will suffice. For example: update_category_product.ex:
# lib/my_app/catalogs/products/update/update_catalog_product.ex
defmodule MyApp.Catalogs.Products.Update.UpdateCatalogProduct do
@moduledoc false
alias MyApp.Accounts.Scope
alias MyApp.Catalogs.Product
alias MyApp.Catalogs.CatalogsProductsAPI
def update_product(%Scope{} = scope, %Product{} = product, %{} = attrs) do
true = product.user_id == scope.user.id
with {:ok, product = %Product{}} <-
product
|> Product.changeset(attrs, scope)
|> Repo.update() do
CatalogsProductsAPI.broadcast_product(scope, {:updated, product})
{:ok, product}
end
end
For complex actions it may be wise to separate in at least into three modules:
Handler- entrypoint module to coordinate the work being done.Core- for pure business logic without side effects.Storage,Queues, etc. - a dedicated module per type of communication with the external world.
The main goal of the Handler is to decouple infrastructure logic from the Business Core logic.
# lib/my_app/catalogs/products/update/update_catalog_product_handler.ex
defmodule MyApp.Catalogs.Products.Update.UpdateCatalogProductHandler do
@moduledoc false
alias MyApp.Accounts.Scope
alias MyApp.Catalogs.CatalogProduct
alias MyApp.Catalogs.CatalogsProductsAPI
alias MyApp.Catalogs.Products.Update.UpdateCatalogProductStorage
alias MyApp.Catalogs.Products.Update.UpdateCatalogProductBusinessRulesCore
def update(%Scope{} = scope, %CatalogProduct{} = current_product, %{} = attrs) do
with {:ok, _scope} <- CatalogsProductsAPI.allowed(scope, :update_catalog_product),
{:ok, %{} = attrs} <- UpdateCatalogProductBusinessRulesCore.enforce(scope, current_product, attrs), # <-- USE WHEN MAKES SENSE
{:ok, catalogs_product = %CatalogProduct{}} <- UpdateCatalogProductStorage.update(catalogs_product, attrs) do
# Ideally we want to broadcast the product update and let other parts of the system react to it:
# - to notify users by sms or email.
# - for analytics
# - business intelligence
# - etc.
CatalogsProductsAPI.broadcast_catalogs_product(scope, {:catalog_product_updated, catalogs_product}) # <-- STRONGLY RECOMMENDED
# An alternative is to trigger the reactions to this update with cross boundary calls via the Domain Resource API.
# Using a cross boundary call to the Domain Resource API avoids direct coupling to the internal of the Domain.
# This is soft coupling, which reduces accidental coupling and complexity.
MailerNotifierAPI.notify_users(scope, {:catalog_product_updated, catalogs_product}) # <-- MIDDLE GROUND RECOMMENDATION
# Bear in mind that by doing this approach of direct cross boundary calls we are coupling this module with anything we interact with.
# This is often called accidental complexity via accidental coupling.
# This may seem innocent when the project is small, but will bite us back when it grows.
UsersMailerNotifier.notify_users(scope, {:catalog_product_updated, catalogs_product}) # <-- NOT RECOMMENDED
{:ok, catalogs_product}
end
end
endCalls to core modules from a Domain Resource Action Handler aren't limited to one. This means we can have more than one Core module per action for whatever we need to do in terms of:
- business rules validation
- data transformation
- data enrichment
- anything else that has no side effects or communicates with the external world.
For example, the with clause on the above example would have some more calls to core modules:
with {:ok, _scope} <- CatalogsProductsAPI.allowed(scope, :update_catalog_product),
{:ok, %{} = attrs} <- UpdateCatalogProductBusinessRulesCore.enforce(scope, current_product, attrs), # <-- USE WHEN MAKES SENSE
{:ok, %{} = attrs} <- UpdateCatalogProductTransformCore.transform(current_product, attrs), # <-- USE WHEN MAKES SENSE
{:ok, %{} = attrs} <- UpdateCatalogProductEnrichCore.enrich(current_product, attrs), # <-- USE WHEN MAKES SENSE
{:ok, catalogs_product = %CatalogProduct{}} <- UpdateCatalogProductStorage.update(catalogs_product, attrs) do
...
endThis split in several Core modules is useful in complex Business Domains, that have complex rules and data transformations/enrichments and whatever else. When the Business Domain is straightforward and simple, then we may not even use a Core module if it doesn't make sense for the current Resource Action being handled.
The main goal of a Core module is to process Business Logic in a deterministic way and without side effects.
# lib/my_app/catalogs/products/update/update_catalog_product_core.ex
defmodule MyApp.Catalogs.Products.Update.UpdateCatalogProductCore do
@moduledoc false
# This must be a pure function. No side effects allowed.
def execute(%Scope{} = scope, %CatalogProduct{} = current_product, %{} = attrs,) do
# Your Business Logic goes here
attrs
end
endThe main goal of the storage module is to decouple database access from the business logic.
# lib/my_app/catalogs/products/update/update_catalog_product_core.ex
defmodule MyApp.Catalogs.Products.Update.UpdateCatalogProductStorage do
@moduledoc false
alias MyApp.Repo
alias MyApp.Accounts.Scope
alias MyApp.Catalogs.CatalogProduct
alias MyApp.Catalogs.CatalogsProductsAPI
def update(%Scope{} = scope, %CatalogProduct{} = current_product, attrs) do
current_product
|> CatalogsProductsAPI.edit(attrs, scope) # <-- calling the changeset via the API
|> Repo.update()
end
endBy using Phoenix code generators with the format <DomainPlural>.<ResourcePlural> to set the domain and resource in the first argument and in the --web option, it will generate code in the web layer (lib/my_app_web) and Business Logic layer (lib/my_app) in a folder structure that is as close as possible of each other, but both layers will require some refactor to comply with the Domain Resource Action architecture pattern.
The most commonly used Phoenix code generators may be mix phx.gen.live and mix phx.gen.auth, for which we have these examples:
mix phx.gen.live Catalogs.Products Product products name:string desc:string --web Catalogs.Productsmix phx.gen.auth Accounts.Users User users --live --web Accounts.Users
See how the --web Catalogs.Products matches exactly the Context module Catalogs.Products namespace given as the first argument to the command.
This approach to create the Phoenix commands is MANDATORY to generate code for the Domain Resource Action architecture pattern.
Every time a code generator is used, that supports the option --web, it must be used in the format -web <DomainPlural>.<ResourcePlural>, e.g. --web Accounts.Users.
Unfortunately a small bug exists in the code generators and the routes will have the resource users duplicated, e.g. http://example.com/accounts/users/users/register, but instead it needs to be http://example.com/accounts/users/register.
To fix this the router.ex needs to be edited to remove the duplication by finding each use of live "/users/... and make it look like live "/...", e.g., from live "/users/register" to live "/register".
Afterwards we run mix compile and fix all warnings about the routes mismatch, e.g., ~p/accounts/users/users/register to ~p/accounts/users/register, by following these steps:
- You MUST run
mix compileto find all module files with routes to be fixed. - You MUST then create a list of files from the the output of
mix compilethat have routes to be fixed. - You MUST feed the list of files to a find and replace tool (e.g.
findin Linux), to fix all routes in only one pass. You MUST NOT try to fix one module at a time, because that will be an exhaustive and time consuming task.
IMPORTANT: See the Authentication document for guidelines on point 3. to understand how to add routes that require authentication.
The Phoenix code generators create a kitchen-sink context module to group all resource actions for a domain in one single module.
While Phoenix contexts look neat and simple, this only holds true at the begin of a project with CRUD actions, but without any business logic applied to them.
As an application grows with more resources and business logic needs to be added to deal with business requirements, then they gradually become hard to reason about, therefore more difficult to maintain and add new features, fix bugs, and to refactor.
Let's use as an example the Phoenix Context MyApp.Catalogs.Products to refactor into the Domain Resource Action architecture.
You MUST follow this steps:
- Rename
lib/my_app/catalogs/products.extolib/my_app/catalogs/catalogs_products_api.ex - Update the module definition from
MyApp.Catalogs.ProductstoMyApp.Catalogs.CatalogsProductsAPI. - Extract each function body from the new module
MyApp.Catalogs.CatalogsProductsAPIinto its own module with only one public function, named after the action, without the resource name, atlib/my_app/catalogs/products/<action>/<action>_catalog_product.ex. For example:lib/my_app/catalogs/products/create/create_catalog_product.exwith a function namedcreate. TheCatalogsProductsAPIfunction header is kept, but its body is now only calling the new action module function, but without usingdefdelegate, otherwise we lose the API contract. You MUST also extract private functions like for thebroadcastaction and make them public. Any access from a module to a Domain Resource Action module needs to go through the API module, direct access is FORBIDDEN. - Update the tests for the now refactored
MyApp.Catalogs.Productscontext to test insteadMyApp.Catalogs.CatalogsProductsAPI. Rename the test file, Module name, and then replace each call to the context with a call to new API module. - Run
mix testto ensure no test is broken after the refactor. If any test is broken fix it before proceeding.
Calls from the web layer, like from a live view or controller are only allowed to a Domain Resource API, that in the folder structure example would be to one of the modules defined at catalogs_categories_api.ex, catalogs_products_api.ex and warehouse_stocks_api.ex. This means that the usual calls to the context need to be replaced with calls to the Domain Resource API. For example, replacing Catalogs.update_product with CatalogsProductsAPI.update_product. The same needs to be done in the respective tests.
Both a live view and a controller must only have logic to deal with web layer concerns, which usually consists in calling a Domain Resource API with the parameters of the request mapped to existing atoms, and dealing with the returned result to decide if the web layer succeeds or fails the response it needs to send back.
The use of atomized attributes is not required to be introduced to existing code, but it's recommended that at some point to refactor the existing code to use them, if not already done.
When this Architecture pattern is analyzed for the first time by an AI Coding Agent it's recommended for it to check if the project is already using atomized parameters to call the Business Logic layer, and if not then ask the developer if he wants to use PLANNING.md to create an Intent with the tasks to implement it, or if he wants to do it himself. The Intent MUST be created as specified by the INTENT_SPECIFICATION.md and exemplified by the INTENT_EXAMPLE.md.
LiveView module trimmed to show only the code for the edit handle event, that's enough to illustrate the call to the Domain Resource API with atomized attributes:
# lib/my_app_web/live/catalogs/products/product_live/form.ex
defmodule MyAppWeb.Catalogs.Products.ProductLive.Form do
...
def handle_event("save", %{"product" => product_params}, socket) do
save_product(socket, socket.assigns.live_action, product_params)
end
defp save_product(socket, :edit, product_params) do
# Atomize the product_params to only pass the ones you are interested in.
# Prevents passing extra parameters from requests sent by attackers. We may
# also want to sanitize the values.
# This line of code isn't generated by any mix phx.gen.*
product_attrs = MyApp.atomize_params_map(["name"])
# The use of the above call to `atomize_params_map/1` is optional, provided
# the project uses another approach to atomize the attributes into a regular
# map or struct. For example, one that atomizes and sanitizes the parameters
# into a struct specific to each action or resource:
# `product_attrs = %CatalogProductInputSanitezed{} = MyAppWeb.Catalogs.Products.sanitize_product_params(product_params)`
case CatalogsProductsAPI.update_product(socket.assigns.current_scope, socket.assigns.product, product_attrs) do
{:ok, product} ->
{:noreply,
socket
|> put_flash(:info, "Product updated successfully")
|> push_navigate(
to: return_path(socket.assigns.current_scope, socket.assigns.return_to, product)
)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
...
endThe below function atomize_params_map/2 is only a simple example of how to
achieve this, but the project may already have another approach to do it.
# lib/my_app.ex
defmodule MyApp do
# This code isn't generated by any mix phx.gen.*
# It needs to be added by us for by the AI code assistant in case we want to
# use this approach.
# We are free to use anything else, provided that it results in atomized
# attributes in a regular map or in a struct.
def atomize_params_map(%{} = params, keys_to_take, keys_to_atomize_values \\ [])
when is_list(keys_to_take) and is_list(keys_to_atomize_values) do
params
|> Map.take(keys_to_take)
|> Enum.into(%{}, fn {key, value} ->
atom_key = String.to_existing_atom(key)
value =
if key in keys_to_atomize_values and is_binary(value),
do: String.to_existing_atom(value),
else: value
{atom_key, value}
end)
end
endA better approach would be to use a dedicated struct for each Domain Resource to accept input from the external world that atomizes the request input parameters into existing atoms and sanitizes their values.
defmodule MyAppWeb.Catalogs.Products.CatalogProductInputSanitized do
# Define here the struct.
# The struct **MUST** explicitly enforce the required attributes.
def sanitize_product_params(input_params) do
# For each atom key in the struct we want to sanitize the input_params value
# present in the corresponding string key.
# You may want to just let it crash if the required keys aren't all present.
# Return the struct.
end
endBy default Phoenix generates schemas and migrations with timestamps in :utc_datetime which have a default precision of seconds, thus making it impossible to order records in database queries by the insert or update times, because several records can be inserted or updated in the same second.
The solution is to modify in config/config.exs, the generators configuration to use timestamp_type: :utc_datetime_usec and the code generators will use them by default when creating migrations and schemas.
If migrations or schemas are being created without the use of the code generator they also must use the :utc_datetime_usec timestamp.
When this Architecture pattern is analyzed for the first time by an AI Coding Agent it MUST check config/config.exs to see if the project is already using binary IDs and if not then ask the developer if he wants to use PLANNING.md to create an Intent with the tasks to implement it, or if he wants to do it himself. The Intent MUST be created as specified by the INTENT_SPECIFICATION.md and exemplified by the INTENT_EXAMPLE.md.
For security reasons this architecture requires that all migrations and schemas use binary IDs for the primary key and foreign keys.
UUIDV7 as binary IDs are strongly recommended because they can be sorted in database queries, once they are time based.
When this Architecture pattern is analyzed for the first time by an AI Coding Agent it MUST check config/config.exs to see if the project is already using binary IDs and if not then ask the developer if he wants to use PLANNING.md to create an Intent with the tasks to implement it, or if he wants to do it himself. The Intent MUST be created as specified by the INTENT_SPECIFICATION.md and exemplified by the INTENT_EXAMPLE.md.
To add support for UUIDV7 we need to follow this steps:
- add to
mix.exsthe dependency{:uuidv7, "~> 0.1"}and runmix deps.get. - modify in
config/config.exs, thegeneratorsconfiguration to usebinary_id: {:id, UUIDv7, autogenerate: true}. - modify the default schema template used by the code generator
phx.gen.schemaandphx.gen.authto use@primary_key {:id, UUIDv7, autogenerate: true}instead of the default@primary_key {:id, :binary_id, autogenerate: true}:
cp deps/phoenix/priv/templates/phx.gen.schema/schema.ex priv/templates/phx.gen.schema/schema.ex.- modify
priv/templates/phx.gen.schema/schema.exline with@primary_key {:id, :binary_id, autogenerate: true}to@primary_key {:id, UUIDv7, autogenerate: true}. cp deps/phoenix/priv/templates/phx.gen.auth/schema.ex priv/templates/phx.gen.auth/schema.ex.- modify
priv/templates/phx.gen.auth/schema.exline with@primary_key {:id, :binary_id, autogenerate: true}to@primary_key {:id, UUIDv7, autogenerate: true}.
- in any schema generated without the code generators always ensure that it uses
@primary_key {:id, UUIDv7, autogenerate: true}instead of the default@primary_key {:id, :binary_id, autogenerate: true}.
The above steps can be easily translated to tasks and sub-task when creating an Intent during the planning phase, that MUST be created as specified by the INTENT_SPECIFICATION.md and exemplified by the INTENT_EXAMPLE.md.