jonnifyelixir
F5 / F5.05 / cqs

F5.05 · part 1 of 3

Command/query separation

Command/query separation is one rule, due to Bertrand Meyer: a function either changes state and returns whether it worked, or returns data and changes nothing — never both. A command does work and reports success or failure; a query answers a question and leaves the world untouched. enroll is a command; courses_of is a query. The payoff is reasoning: a query can be called twice, cached, or run in parallel because it has no effect, and a command is honest about the fact that it changes things. It also nudges enroll away from returning a read model — on success it reports :ok, and the caller queries for the result.

Two kinds of function, one job each. Select one to see what it returns and what it touches.

Change or read, not both

The trouble with a function that both changes state and returns data is that you can never call it safely only to look — every read risks a write. Separating the two means a query is a free, repeatable observation, and a command is the only thing that can move the engine. Select a kind.

Command or query · select one

COMMAND changes state · returns :ok | {:error, _} writes QUERY returns data · changes nothing reads

kind: Command

returns: :ok | {:error, _}

If a function returns data, it must be safe to call again. The only way to promise that is to keep it from changing anything — which is the whole rule.

In code

Two functions, two shapes. enroll changes state and returns only a tag; courses_of returns data and runs no effect. After a successful command, the route renders by issuing a query — the write path and the read path stay separate.

# command — changes state; returns success or failure, not a read model
@spec enroll(String.t(), String.t()) :: :ok | {:error, atom}
def enroll(user_id, course_id) do
  with :ok <- check_precondition(user_id, course_id) do
    emit(%LearnerEnrolled{user_id: user_id, course_id: course_id})   # record the change
  end
end

# query — returns data; changes nothing, safe to repeat or run in parallel
@spec courses_of(String.t()) :: [Enrollment.t()]
def courses_of(user_id), do: State.enrollments_for(user_id)

ask or act

A query observes for free; a command is the only thing that moves the engine.

enroll, then query

The command reports :ok; the caller reads back the result.

Next: domain events — the past-tense facts a command emits when it changes state.

References

Primary sources for this lesson, and where it connects in the course.

Sources

Related in this course