A method named getBalance looks harmless. A developer may call it twice, use it while debugging, or add it to a log statement without expecting the program to change. If that method also clears pending adjustments, increments a counter, or refreshes state, those ordinary actions can alter behaviour.
The underlying design problem is not simply a poor method name. Reading information and changing state have different consequences, yet one operation is doing both.
Command-query separation is a useful mental model for making that difference visible. A query returns information without changing the externally observable state of the system. A command changes state and does not use its return value to answer a domain question. Separating the two makes code easier to reason about because callers can tell whether an operation observes the world or changes it.
This article explains how to apply that idea in everyday code, where the boundary becomes less obvious, and when combining a result with a state change is still the more practical contract.
Start with one question: does this call change what happens next?
Consider a small queue:
class JobQueue:
jobs = []
function nextJob():
job = jobs.first()
jobs.removeFirst()
return jobnextJob returns useful information, but it also removes that job from the queue. Calling it a second time produces a different result because the first call changed the queue.
That may be exactly the intended operation. The problem appears when callers treat it as inspection:
log("next job", queue.nextJob())
job = queue.nextJob()
process(job)The logging statement consumes one job. The job that gets processed is the following one.
A useful first test is therefore:
If I call this operation only to learn something, can that call change what a later operation observes or does?
If the answer is yes, the operation has command behaviour even if it also returns a value.
Give observation and mutation separate operations
If callers need both inspection and removal, make those intentions explicit:
class JobQueue:
jobs = []
function peek():
return jobs.first()
function removeFirst():
jobs.removeFirst()Now peek is a query. Calling it repeatedly against unchanged queue state returns the same first job and does not consume anything.
removeFirst is a command. Its purpose is to change the queue.
The benefit is not that two methods are inherently better than one. The benefit is that the interface exposes an important behavioural distinction. A reader can see where mutation is possible without knowing the implementation.
That distinction becomes especially valuable in code that composes operations:
job = queue.peek()
if canProcess(job):
queue.removeFirst()
process(job)The control flow now says when the program merely observes the queue and when it commits to changing it.
This example is deliberately small. Production queues may need atomic claim operations to handle concurrency safely; separating peek and removeFirst is not a recommendation for implementing concurrent work queues. The example isolates the command-query design idea.
A query should preserve externally observable state
“Does not change state” needs a practical definition. Many query implementations still perform internal work.
Suppose priceFor(product) calculates a price and stores the result in a private cache. A later call can reuse that cached value. Technically, memory changed. From the caller’s perspective, however, the cache may be an implementation detail if it does not change the returned domain result or other behaviour promised by the component.
That leads to a more useful rule: a query should not change externally observable state as defined by its contract.
Internal caching can fit that rule when callers cannot observe a semantic difference. But the details matter. If filling the cache changes eviction behaviour that callers rely on, persists data as part of a public contract, emits a business event, or causes another meaningful side effect, treating the operation as a pure query becomes misleading.
The same reasoning applies to metrics and tracing. Recording an internal timing metric is operationally a side effect, but it usually does not turn a domain lookup into a command. The important question is whether the effect is part of the behaviour callers must reason about.
Commands make state transitions explicit
Commands become easier to understand when their names describe the intended state transition.
Compare these operations:
account.status()
account.update()with:
account.status()
account.suspend()status reads naturally as a query. suspend announces a state change. update says little about what changes or why.
Clear command names help reviewers and maintainers trace mutation through a workflow. They also make permission checks, validation, logging, and failure handling easier to place because the state-changing operation has an explicit boundary.
A command can fail. For example, suspend() may reject an account that is already closed. Command-query separation does not imply that commands are simple assignments or that every transition succeeds. It only makes the intent to change state visible.
Do not turn the rule into a ban on useful return values
A strict interpretation says that commands should return no value. That is a useful design pressure, but real interfaces often need to report the outcome of a state-changing operation.
Consider creating an order:
result = orders.create(request)The operation changes state and may need to return an assigned order identifier. Forcing callers to perform a separate lookup can add complexity, create another failure point, or make the relationship between the command and its result less clear.
The important distinction is between returning the outcome of a command and disguising a query as a state-changing operation.
A result such as this can be reasonable:
CreateOrderResult:
orderId
acceptedAtThe caller still knows that create is a command. Its return value reports what happened because of that command; it does not make the operation observational.
Use the principle to clarify contracts, not to force awkward APIs merely to satisfy a literal rule.
Watch for queries that hide expensive or risky effects
Mutation is not the only surprise a query-like interface can hide.
A method named customer.profile() might perform a remote request. It may not change domain state, so it can still be a query in the command-query sense. But its latency and failure modes are very different from reading an in-memory field.
Command-query separation does not communicate cost, network boundaries, blocking behaviour, or availability guarantees. Those concerns need their own design signals and documentation.
This matters because “query” does not mean “cheap” or “cannot fail.” A query can read a database, cross a process boundary, time out, or return stale data. The principle tells you about intended state change, not every operational property of the call.
Avoid splitting operations that must be atomic
Sometimes observation and mutation belong in one indivisible operation.
Return to the job queue. In a concurrent system, this sequence is unsafe without additional coordination:
job = queue.peek()
queue.removeFirst()Another worker could change the queue between those calls. An atomic operation such as claimNextJob() may be the correct contract even though it both changes state and returns the claimed job.
The combined operation expresses one business action: claim exactly one available job. Splitting it solely to achieve textbook command-query separation would weaken the design.
Similar cases include compare-and-set operations, resource allocation, and transactions that must return the value they created or claimed.
When atomicity matters, preserve atomicity. Make the mutating nature explicit through the operation name and contract instead of pretending the operation is a query.
Use the principle where ambiguity causes mistakes
Command-query separation is most useful when an interface contains operations that look observational but secretly mutate important state, or when state changes are scattered through methods whose names do not reveal them.
A practical refactoring process is:
- Identify an operation whose callers cannot easily tell whether it changes state.
- List the state it observes and the state it changes.
- Ask whether observation and mutation represent separate caller intentions.
- If they do, extract a query for observation and a command for the state transition.
- If they must remain atomic, keep them together and name the operation as a state-changing action.
- Check callers after the change; splitting an operation can alter concurrency, error handling, or performance characteristics.
Do not mechanically split every method that both mutates and returns something. Constructors, collection removal operations, atomic claims, and transactional writes often have legitimate result values. The goal is predictable intent, not structural uniformity.
Conclusion
Command-query separation gives developers a simple way to reason about an interface: distinguish operations that answer questions from operations that change the system.
Use queries when callers should be able to inspect state without causing a meaningful state transition. Use clearly named commands when the caller intends to change state. Allow commands to report their outcomes when that makes the contract more useful, and keep observation plus mutation together when they must be atomic.
The practical test is not whether every method fits a rigid definition. It is whether a caller can understand, before making a call, whether that call merely observes the system or changes what happens next.