Let’s analyze how a toggle button, for turning activity on and off, can be designed and implemented, on the server-side.
Lack of idempotency leads to unexpected results
Since it is called a toggle in UI, let’s call the interface on the server-side that way. A user clicks the toggle, the app sends the request:
PATCH /api/user/activity_status/toggle
and the system runs the following interface:
def toggle(user_id) do
user_status = get(user_id)
if user_status.active do
:ok = user_turns_off_active_status(user_id)
else
:ok = user_turns_on_active_status(user_id)
end
:ok
end
Does it work? Yes, it does. But what happens when the request is sent twice by accident? How do we want our system to react? Should the user be active or inactive?
First of all, the interface is not idempotent. It means that invoking the function several times doesn’t produce the same result. Finally, the name of the interface is deprived of ubiquitous language [E. Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, p. 24] that could indicate what result is expected.
There is one more problem. What if the system automatically turns off the active status after 10 PM? We need to add the if statement to verify whether the user is active or not to make them inactive:
if UserActivity.get(user_id).active do
:ok = UserActivity.toggle(user_id)
end
Has it helped? Let’s see what happens if at the same time the user tries to turn off their active status and the system does it too?
When both requests reach the if statement the user is active. Then the user’s request invokes the toggle/1 function and turns off active status. Right after the worker runs the same interface. This time the status changes back to active as the user’s request has just turned the active status off.
To guarantee the expected by user result, we need to do concurrency control by wrapping the logic into the transaction and locking the table. As a result, simple behavior, that has been implemented incorrectly, forced us to add extra lines of code.
Using idempotent interfaces to change state
The one way to avoid mentioned problems is to modify endpoint and pass parameters that clearly indicate what results are expected:
PATCH /api/user/activity_status
{
active: true/false
}
Then the controller decides which interface should be run based on the value of the active field:
def activity_status(conn, %{active: active}) do
user_id = conn.assigns.user_id
if active do
UserActivity.user_turns_on_active_status(user_id)
else
UserActivity.user_turns_off_active_status(user_id)
end
# ...
end
Names of interfaces say about the end-state, so invoking one of them you know exactly what happens. Even if you do that several times the result will always be the same. What’s more, they clearly show how the domain works, so you don’t need to wonder what the purpose of these interfaces is.