Reorganizing your (Phoenix) Contexts as Use Cases

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.

Contexts example

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? Link to heading

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 some user/actor performs on your application, like a button click in the UI, a command entered via CLI, or even a response from an 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.

Scumbag brain

There’s nothing technically wrong with defining all these functions inside a single module in Elixir, but as contexts grow larger, you start to have too many functions related to each other but loosely related to the parent concept. The cognitive overhead of building a mental state of all these functions is too much.

Writing a Use Case Link to heading

Steps for writing a Use Case:

  1. identify the actions a user can perform
  2. extract each action in its own Use Case module
  3. PROFIT!

No joke, that’s it. Here’s an example of 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, &notify_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 does 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 subcontext order and named AlchemyReaction.Sales.Orders.CancelOrderManually.

You make the requirements explicit

It’s clear that to cancel the order manually, we require the order itself, who is doing it, and the reason. 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 vary depending on the execution context. For example, the cancellation reason may be required to manually cancel the order by an operator in the customer support, but maybe it’s not by the fraud detection department. It is a terrible example, but I hope I can get you through.

Also, note that these requirements are strictly related to what is needed to execute the Use Case, it has nothing to do with roles and permissions. Both the operator in customer support and the analyst in the fraud detection have permission to cancel an order manually.

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 often 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 unique name and will identify its result in case of success or failure. - Ecto.Multi docs

By always returning an Ecto.Multi you can reuse other Use Cases, composing them together by appending/merging them into the parent multi. Think of canceling an order where you also have to cancel the payment, put back items in stock, and maybe schedule an email inviting the user to buy again in 2 weeks. Each of these use cases can be executed by itself, giving other contexts.

I use Ecto.Multi even when there’s no database operation. The cost of opening/closing an unutilized transaction is negligible.

Ecto.Multi is love. Ecto.Multi is life. :heart: :heart: :heart:

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. However, having multiple options and knobs resulting in multiple execution paths and outputs is a red flag.

Identifying a Use Case Link to heading

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.

USE THE VERBS, LUKE

Pay attention to the language they use, especially 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 you must 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 Link to heading

Writing this much additional code to do the same thing as the traditional way may seem cumbersome. 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 various languages.