An object can expose perfectly reasonable getters and still make a system difficult to change. The problem appears when callers repeatedly read those values, interpret them, and then decide which mutation is allowed.
Suppose several parts of an application do this:
if order.status == "pending" and order.paymentReceived:
order.status = "confirmed"The caller is not merely using data. It knows the rule for confirming an order. If another caller needs the same behavior, that rule is likely to be copied. When the rule changes, every copy becomes a place that can disagree.
A useful alternative is an intention-revealing operation: an operation named for what the caller wants to accomplish, while the object or module that owns the relevant state decides whether and how that request changes the state.
This article explains how to recognize query-then-act code, how to move decisions without hiding useful information, and when direct data access remains the simpler design.
The important distinction is information versus decision
Reading state is not inherently a design problem. A reporting screen may need an order’s status. A serializer may need fields to produce a response. A diagnostic tool may need to inspect an object’s current values.
The stronger signal is this sequence:
- ask an object about its state;
- use that state to make a decision about the object;
- tell the object how to change.
That is query-then-act code.
For example:
if cart.itemCount > 0 and not cart.checkedOut:
cart.checkedOut = trueThe caller has learned two pieces of state and reconstructed the rule for checkout. The rule is now outside the thing whose state it governs.
An intention-revealing API changes the conversation:
cart.checkout()The short call is not the main benefit. The important change is ownership. Cart now owns the decision about whether checkout is valid because it already owns the state needed to make that decision.
A useful mental model is:
Expose information when another component genuinely needs information. Expose an operation when another component wants something done.
That distinction is more useful than trying to eliminate getters as a rule.
Start with the smallest useful example
Consider a support ticket that may be closed only after it has been resolved.
A data-oriented version might look like this:
if ticket.status == RESOLVED:
ticket.status = CLOSED
else:
return error("ticket must be resolved first")This works. The weakness appears when closing a ticket is possible from an HTTP handler, an administrative tool, and an automated workflow. Each caller can reproduce the same condition.
Instead, give the ticket an operation that represents the requested transition:
ticket.close():
if status != RESOLVED:
return error("ticket must be resolved first")
status = CLOSED
return successCallers now say:
result = ticket.close()The rule has one authoritative location. If tickets later require both RESOLVED status and an assigned resolution code, the change belongs inside close() rather than in every caller that happens to close tickets.
This example is deliberately small. In production code, persistence, authorization, notifications, and transaction boundaries may live elsewhere. The design point is narrower: the rule that interprets the ticket’s own state should not need to be reconstructed by every caller.
Name the request, not the field change
An intention-revealing operation should describe a meaningful request. Merely wrapping a setter usually does not improve ownership.
Compare:
order.setStatus(CONFIRMED)with:
order.confirm()setStatus(CONFIRMED) tells the object the desired representation. The caller may still need to know which transitions are valid, what related fields must change, and what conditions permit confirmation.
confirm() expresses intent. It gives the implementation room to protect those rules:
order.confirm():
if status != PENDING:
return error("only pending orders can be confirmed")
if not paymentReceived:
return error("payment is required")
status = CONFIRMED
confirmedAt = clock.now()
return successThe exact API shape depends on the language and application. The operation might return a result, raise a domain-specific error, or produce a new immutable value. Those choices are secondary to the ownership rule: callers request confirmation; the component that owns confirmation rules decides what confirmation means.
Move one complete decision at a time
A safe refactoring does not begin by hiding every field. Start with one repeated decision whose inputs mostly belong to one object or module.
Suppose callers contain this logic:
if subscription.active and subscription.remainingCredits >= cost:
subscription.remainingCredits -= cost
return success
else:
return insufficientCreditsA practical refactoring is:
subscription.consumeCredits(cost):
if not active:
return inactiveSubscription
if remainingCredits < cost:
return insufficientCredits
remainingCredits -= cost
return successThen callers become:
result = subscription.consumeCredits(cost)Notice what moved together:
- the conditions that permit the change;
- the state mutation;
- the outcome that explains why the request succeeded or failed.
Moving only the subtraction while leaving the eligibility check outside would split the rule rather than centralize it.
After the new operation exists, replace other copies of the old decision. Only then consider whether the underlying state still needs to be publicly mutable.
Keep orchestration outside when the decision spans boundaries
Not every decision belongs inside one object. A checkout workflow may depend on inventory, payment authorization, fraud screening, and shipping availability. Forcing all of those dependencies into an Order object can make the object responsible for infrastructure and coordination that it does not own.
The useful question is: where does the knowledge required for this decision naturally live?
If a rule depends only on an order’s own status and payment state, order.confirm() is a natural home. If a rule requires several external systems, an application service may coordinate them:
placeOrder(orderId):
order = orders.load(orderId)
reservation = inventory.reserve(order.items)
payment = payments.authorize(order.total)
if reservation.failed or payment.failed:
compensateAsNeeded()
return failure
return order.markPlaced(payment.reference)The service owns orchestration across boundaries. The order still owns the validity of its own state transition.
This separation avoids two opposite mistakes: callers should not duplicate local object rules, but objects also should not become containers for every workflow that happens to mention them.
Return enough information for the caller’s job
Moving a decision inward does not mean callers should become blind to outcomes.
A user interface may need to explain why checkout failed. A workflow may need to distinguish a retryable conflict from a permanent rejection. Returning only true or false can force callers to query state again to guess what happened.
Prefer an outcome that carries the distinction the caller legitimately needs:
consumeCredits(cost) ->
Consumed(newBalance)
| InactiveSubscription
| InsufficientCredits(available, required)Now the operation owns the decision while the caller owns presentation or workflow handling:
result = subscription.consumeCredits(20)
if result is InsufficientCredits:
show("You need 20 credits but have " + result.available)This preserves encapsulation without hiding useful consequences.
Be careful not to return the object’s entire internal state merely to make every possible future caller convenient. Return the information required by the contract of the operation.
This design can protect invariants
An invariant is a condition that should remain true for a valid object or module state. Query-then-act code can weaken invariants when callers can bypass the decision and mutate fields directly.
Imagine an account where balance must never become negative through a normal withdrawal. If callers do this:
if account.balance >= amount:
account.balance -= amountcorrectness depends on every caller remembering the check.
An operation can make the rule harder to bypass:
account.withdraw(amount):
if amount <= 0:
return invalidAmount
if balance < amount:
return insufficientFunds
balance -= amount
return withdrawnIf direct balance mutation is no longer exposed, ordinary callers cannot accidentally perform the state change without the checks.
There is also a concurrency boundary to notice. If two processes can modify the same persisted account concurrently, an in-memory check alone does not guarantee that the persisted balance remains valid. The storage update still needs an appropriate concurrency mechanism, transaction, atomic conditional update, or other protection for the system’s consistency requirements.
Encapsulating the business decision improves code ownership; it does not replace database or distributed-concurrency guarantees.
Avoid turning every getter into a command
The idea becomes counterproductive when applied mechanically.
A caller that genuinely needs data should be allowed to query data. For example:
invoice.total()
invoice.dueDate()may be exactly what a renderer or reporting component needs. Replacing those queries with awkward operations such as invoice.renderYourselfIntoThisReport() can couple domain code to presentation concerns.
Likewise, a simple immutable data structure may intentionally expose values without behavior. Configuration records, data-transfer objects, parsed messages, and read models often exist primarily to carry information. Adding behavioral methods merely to avoid getters can make their role less clear.
Use intention-revealing operations when callers are interpreting owned state to decide how that same state should change. Do not use them to erase useful separation between computation, presentation, persistence, and data transport.
Watch for operations that become vague dumping grounds
A method named process(), handle(), or update() may technically hide decisions but reveal little about intent.
Compare:
order.process()with:
order.cancel(reason)
order.confirmPayment(reference)
order.releaseReservation()The more specific operations communicate what transitions the abstraction supports. They also make permissions, tests, and failure outcomes easier to discuss because each operation has a clear purpose.
Another warning sign is a method with many mode flags:
order.update(force, notify, skipValidation)That API moves branches behind a method boundary but still asks callers to understand implementation modes. Separate operations or a clearer workflow abstraction may express the valid intentions better.
Test behavior at the operation boundary
Once an abstraction owns a decision, tests can focus on its contract rather than on internal fields.
For consumeCredits, useful cases include:
active subscription with 50 credits
consume 20
=> succeeds with 30 remaining
active subscription with 10 credits
consume 20
=> insufficient credits; balance remains 10
inactive subscription with 50 credits
consume 20
=> inactive; balance remains 50These tests state what callers can rely on. They also protect an important failure property: rejected operations do not partially mutate the balance.
Tests that directly set internal fields may still be useful at lower levels, depending on the implementation, but the public behavior should be testable through the same operations production callers use.
When query-then-act is acceptable
A query followed by an action is not automatically wrong. It can be appropriate when the query and action concern different responsibilities.
For example, a controller may query whether the current user has a presentation preference and then choose a response format. A monitoring component may inspect queue depth and emit a metric. A report generator may read several objects and calculate a summary without mutating them.
Even when mutation is involved, a one-off decision in a small, local module may be clearer than introducing a new abstraction. The pressure to move behavior grows when the same rule is repeated, when callers can create invalid states, or when a change to one business rule requires edits across unrelated callers.
Use the design to reduce duplicated decision knowledge, not to satisfy a slogan.
A practical review question
When reviewing code, look for sequences shaped like this:
value = thing.someState
if value means condition X:
thing.changeState(...)Then ask:
Does this caller need the information, or does it really want the object to perform an operation?
If the caller only needs an outcome, move the interpretation of owned state toward the component that owns that state and expose the caller’s intent as an operation. Keep cross-system orchestration where the participating boundaries can be coordinated clearly, and return enough outcome information for callers to do their own jobs.
The result is not simply fewer getters. It is fewer places that need to know the same rule. That is the maintainability benefit that makes intention-revealing operations useful.