Throughout my years of web development with Elixir and Phoenix, I have often struggled with how to handle business logic, particularly when it comes to status changes.
I believe web features are inherently simple; almost everything has been done before. But they can become complex because of the thousands of cases we have to handle for client needs, exceptions, and niche desires. In Phoenix, managing all of this in context modules can easily turn your project into a maze of conditional logic.
Take a basic “Invoices” feature. Your project manager tells you that an invoice should begin as unpaid. When someone pays, it becomes cleared, and the user gains access to whatever they purchased. Straightforward. Then they add: sometimes users overpay — that should still mark the invoice as cleared, but also create a reimbursement. Other times users underpay — now the invoice should become incomplete. And remember that initial unpaid invoice? On initialization, you’re also supposed to check for existing reimbursements for that month and add those amounts to the invoice, possibly changing its initial status to incomplete.
There will be more edge cases. There always are. Before long, your Invoices context becomes a sprawling module full of conditionals, helper functions, and “just one more rule.” You know that feeling when business logic starts leaking everywhere and future you is going to suffer.
A better approach is to isolate status transitions and their after-effects in one place — a state machine.
According to the MDN glossary:
A state machine reads a set of inputs and changes to a different state based on those inputs.
What that definition leaves out is the real power: we can use these transitions as controlled entry points for all the business logic that needs to happen when a status changes — timestamps, reimbursements, audit logs, notifications, whatever your domain requires.
Returning to our Invoices example, we can define a small state machine using FSMX:
defmodule MyApp.Invoice do
use Ecto.Schema
use Fsmx.Struct, fsm: MyApp.InvoiceStateMachine, state_field: :status
schema "invoices" do
field :status, values: [:unpaid, :incomplete, :cleared], default: :unpaid
end
end
defmodule MyApp.InvoiceStateMachine do
use Fsmx.Fsm,
state_field: :status,
transitions: %{
:unpaid => [:incomplete, :cleared],
:incomplete => [:cleared]
}
end
# usage
{:ok, invoice} = Fsmx.transition(invoice, :cleared)
# or
{:error, reason} = Fsmx.transition(invoice, :some-invalid-state)
This is where things become interesting. Instead of scattering logic across controllers and context functions, we can define explicitly what happens during status transitions. For example, when an invoice moves from unpaid to cleared, we can record a timestamp, check for overpayment, and create a reimbursement in one place. When it moves to incomplete, we can compute the outstanding amount and create a follow-up record. All of that logic lives with the transition, not hidden somewhere else.
defmodule MyApp.InvoiceStateMachine do
use Fsmx.Fsm,
state_field: :status,
transitions: %{
:unpaid => [:incomplete, :cleared],
:incomplete => [:cleared]
}
use Ecto.Changeset
def transition_changeset(changeset, _from, :cleared, %{paid_amount: paid}) do
changeset
|> put_change(:cleared_at, DateTime.utc_now())
|> maybe_cast_reimbursement(changeset.data.total_amount, paid)
end
def transition_changeset(changeset, _from, :incomplete, %{paid_amount: paid}) do
outstanding = changeset.data.total_amount - paid
put_change(changeset, :outstanding_amount, outstanding)
end
defp maybe_cast_reimbursement(changeset, total, paid) when paid > total do
reimbursement_amount = paid - total
cast_assoc(
changeset,
:reimbursement,
with: fn reimbursement, _params ->
change(reimbursement, %{amount: reimbursement_amount})
end
)
end
defp maybe_cast_reimbursement(changeset, _total, _paid), do: changeset
end
That’s the idea: model not just what states are allowed, but what happens when we enter them.
State machines turn messy conditional flows into explicit business rules. They give us a single, documented source of truth for how things change, and why. Once you start thinking of them as business logic engines — not just traffic guards — your Elixir applications become easier to reason about, easier to test, and much easier to extend.
If you’re building anything with chained status changes, unexpected side effects, or a growing list of “one more rule,” try moving that logic into a state machine. It may be the cleanest refactor you can make.
Top comments (0)