Contexts are a great concept to help you identify well defined boundaries between the different areas of your application. There is nothing new or specific about Phoenix Contexts, it's just a subset - identification and organization - of Bounded Contexts, defined by Eric Evans in the excellent Domain-Driven Design: Tackling Complexity in the Heart of Software.
In this post I'll show you another way to organize your Contexts by extracting actions into their own Use Cases modules to convey better clarity and make requirements explicit.
What is a Use Case?
A use case is a written description of how users will perform tasks on your website. It outlines, from a user’s point of view, a system’s behavior as it responds to a request. Each use case is represented as a sequence of simple steps, beginning with a user's goal and ending when that goal is fulfilled. - Usability.gov
In other words, a Use Case is an action that some user/actor performs on your application, like a button click in the UI, a command entered via CLI or even a response from a external API. It is something that the actor does and expects a side effect: a data change, sending an email, a background calculation.
Phoenix already creates three generic use cases when you use its generator to create or update a context: create_*/1
, update_*/2
and delete_*/1
functions. In a Sales
context with Client
and Order
, Phoenix defines the following functions:
create_client/1
update_client/2
delete_client/1
create_order/1
update_order/2
delete_order/1
Now add order items, payments, addresses and the concept of checkout to the context. Also add the other necessary accompanying functions like data validation, policies verification, external data fetching, email sending and event logging, and the number of functions will increase drastically.
There's nothing technically wrong defining all these functions inside a single module in Elixir, but as contexts grows larger you start to have too many functions related to each other but loosely related to the parent concept. The cognitive overhead to build a mental state of all these functions is too much.
Writing a Use Case
Steps for writing a Use Case:
- identify the actions a user can perform
- extract each action in its own Use Case module
- ...
- PROFIT!
No joke, that's it. Here's an example for an order cancellation:
defmodule AlchemyReaction.Sales.CancelOrderManually do
@moduledoc """
Cancels an order with the given reason and notifies the client via email.
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Multi
alias AlchemyReaction.Sales.Order
embedded_schema do
field :reason
end
def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:reason])
|> validate_required([:reason])
|> validate_length(:reason, min: 10)
end
def call(%Ecto.Changeset{valid?: true} = changeset, %{order: order, actor: actor} = _deps) do
data = apply_changes(changeset)
Multi.new()
|> Multi.update(:order, Order.changeset(order, %{status: "cancelled"}))
|> Multi.run(:audit, &cancel_order_audit(&1, &2, data.reason, actor))
|> Multi.run(:email, ¬ify_client(&1, &2, actor))
end
defp cancel_order_audit(_repo, %{order: order}, reason, actor) do
# ...
end
defp notify_client(_repo, %{order: order}, actor) do
# ...
end
end
The key benefits of writing Use Cases are:
Code organization and clarity
You can know, or at least have an idea, what each context do just by looking at the file structure. You can also group the Use Cases in sub contexts folders on larger contexts for better organization. In this case the CancelOrderManually
would be nested in the sub context order
and named as AlchemyReaction.Sales.Orders.CancelOrderManually
.
You make the requirements explicit
It's clear that a reason, the order being cancelled and the actor who is doing it are neededed to manually cancel the order. Here I'm passing the order
and the actor
as dependencies to the call/2
function because I'm loading them outside, mainly for checking permissions, but you could have the order_id
and actor_id
defined in the schema and load them inside the use case.
Keep in mind that requirements varies depending on the execution context. A reason is required to manually cancel an order by an operator in the customer support, but it may not be required by the analist in the fraud detection department. This is a terrible example, but I hope I can make you through.
Also note that these requirements are strictly related to what is needed to execute the Use Case, there's nothing to do with roles and permissions. Both the operator in the customer support and the analyst in the fraud detection have permissions to manually cancel an order.
Use the Use Case to build your forms
The changeset serves as the data structure to build your forms the same way you would use an Order
schema in a traditional way (you will need to set the as
namespace and the submission method
manually as the form_for/x
helper can't infer from the struct).
You are decoupling the requirements to execute the use case from the underlying implementation, as oftentimes a change can span multiple schemas.
It's easy to reuse and compose Use Cases
You must always return an Ecto.Multi
from the call/2
function. Always is a strong word, but I really mean ALWAYS!
Ecto.Multi makes it possible to pack operations that should be performed in a single database transaction and gives a way to introspect the queued operations without actually performing them. Each operation is given a name that is unique and will identify its result in case of success or failure. - Ecto.Multi docs
By always returning a multi you can reuse other Use Cases, composing them together by appending/merging them into the parent multi. Think of cancelling an order where you also have to cancel the payment, put back the items in stock and maybe scheduling an email for inviting the user to buy again in 2 weeks. Each of these use cases can be executed by themselves giving other contexts.
I use Ecto.Multi
even when there's no database operation. The cost of opening/close an unutilized transaction is negligible.
Ecto.Multi
is love. Ecto.Multi
is life. ❤️ ❤️ ❤️
Don’t make your Use Cases generic or (overly) configurable
One of the key benefits of a Use Case is that it reflects a single, defined, finite execution path in the application. If you try to make it generic enough to handle multiple use cases, then you lose the benefit of clarity and context.
Having a flag to indicate that an email notification should be sent after performing the use case is ok. Having multiple options and knobs resulting in multiple execution paths and outputs is a red flag.
Identifying a Use Case
This is the hardest part. It's a constant process of mapping and understanding why and how data changes by analyzing the day to day of the application's users and the corner cases that surface from time to time.
Pay attention to the language they use, specially the verbs. Verbs denote actions and actions are what you're interested in.
It's ok if you don't get it right the first (or second, or third) time. But it's important you keep refining...
Some other examples of Use Cases:
-
Sales
context:ShipOrder
,RefundOrder
,ChangeShippingAddress
,CancelOrderViaPaymentGateway
(naming is hard); -
Accounts
context:ChangePrimaryEmail
,ChangePassword
,DeactivateAccount
,ConfirmEmailAddress
,SetPreferredPaymentMethod
,SetPreferredShippingAddress
;
Conclusion
It may seem cumbersome to write this much code to do the same thing as the traditional way. I don't have any metrics, but my feeling is that it's not much more, it could even be the same. You're not just typing code out, you're mapping every operation and process of the application, identifying and documenting every execution path.
I've been using this concept of Use Cases with great success for multiple years, in applications written in Elixir, Ruby and even PHP.
Top comments (1)
Great read. I wonder why haven't you abstracted even further the Use cases, since they are coupled in your example with Ecto. And as far as I remember inner items in DDD should not know nothing about the outer layers(DB) in your example