toggle button

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?

toggle issue

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.