Engineering

Make your system more predictable by using idempotent interfaces

Let's analyze how a toggle button that turns 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 this way. A user clicks the toggle, and 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 a 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 a 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, a user tries to turn off their active status and the system does it too.

When both requests reach the if statement a user is active. Then, a user's request invokes the toggle/1 function and turns off active status. Right after, a worker runs the same interface. This time the status changes back to active as a user's request has just turned the active status off.

To guarantee a user gets the expected 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 made us add extra lines of code.

Using idempotent interfaces to change state

The one way to avoid the above mentioned problems is to modify an endpoint and pass parameters that clearly indicate what results are expected:

PATCH /api/user/activity_status

{
  active: true/false
}

Then, a 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 tell us about the end-state, so while 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.