A class can hide its fields and still force every caller to understand its rules. The usual symptom is code that asks an object for several values, makes a decision with those values, and then tells the object what state to change.
That design spreads knowledge. When the rule changes, every place that reconstructed the rule may need to change too.
Tell, Don’t Ask is a design heuristic for reducing that problem. Instead of asking an object for internal information so another object can decide what should happen, tell the object the intent and let the component that owns the relevant rules make the decision.
This article develops that idea from a small example, shows why it can reduce change coupling, and explains where the heuristic stops being useful.
Start with the decision, not the getter
Imagine an order that can receive a discount when its subtotal reaches a threshold. A caller asks for the subtotal and then changes the discount:
if order.subtotal() >= 100:
order.setDiscount(10)The fields may be private, but the discount rule is not encapsulated. The caller knows:
- which value determines eligibility;
- the threshold of
100; - the discount of
10; - which mutation applies the result.
If several checkout paths repeat this logic, changing the rule means finding all of them.
A Tell, Don’t Ask design moves the decision to the component that owns the rule:
order.applyVolumeDiscount()A simplified implementation might be:
applyVolumeDiscount():
if subtotal() >= 100:
discount = 10The important change is not that two lines became one call. The caller now expresses an intent—apply the relevant volume discount—without reconstructing the rule.
The mental model: move knowledge toward its owner
The heuristic is easier to use if you think in terms of knowledge rather than syntax.
Suppose code performs these steps:
status = invoice.status()
balance = invoice.balance()
if status == "open" and balance > 0:
invoice.markOverdue()Ask one question:
Which component should know the rule that an open invoice with a positive balance can become overdue?
If that rule is an invariant of Invoice, making callers reconstruct it gives those callers knowledge they do not otherwise need. The invoice can instead expose behavior:
invoice.markOverdueIfEligible()or, if attempting an invalid transition should be rejected explicitly:
invoice.markOverdue()with the invoice checking the transition itself:
markOverdue():
if status != "open":
reject "only open invoices can become overdue"
if balance <= 0:
reject "paid invoices cannot become overdue"
status = "overdue"Now the invariant has one owner. A caller can request the transition, but it cannot bypass the rule merely by forgetting one of the checks.
This is the practical value of Tell, Don’t Ask: put a decision close to the state and rules required to make that decision when that component is the natural owner of them.
Asking is not the problem by itself
The name of the heuristic is easy to take too literally. It does not mean getters are forbidden, queries are bad, or objects should never return data.
Reading data is appropriate when the caller genuinely owns the next decision.
For example, a reporting component may need an invoice balance to render a financial statement:
report.addMoney("Outstanding", invoice.balance())The report is not deciding how the invoice behaves. It is presenting information. Moving report formatting into Invoice would mix unrelated responsibilities.
Likewise, an API serializer may legitimately ask an object for values needed to produce a representation. A search algorithm may query candidate scores because comparing candidates is its responsibility.
The useful distinction is not tell versus any read. It is:
- Is the caller reading information because the caller owns a separate responsibility?
- Or is the caller reading information mainly to reproduce a decision that belongs with the object being queried?
Only the second case is a strong signal for moving behavior.
Watch for ask-decide-command sequences
A common design smell has three stages:
value = object.query()
if rule(value):
object.command()The query itself may be harmless. The concern is that the caller knows both the condition and the state-changing action.
Consider a subscription:
if subscription.remainingDays() == 0:
subscription.expire()This looks small, but imagine the rule later becomes:
- expire when no paid days remain;
- unless a grace period is active;
- unless renewal payment is still being retried.
Every caller that owns the original remainingDays() == 0 check becomes a possible source of inconsistent behavior.
If expiration is a subscription rule, a stronger boundary is:
subscription.expireIfDue(now)The subscription can then evolve its eligibility logic without teaching each caller the new rule.
Notice that now is passed in. Tell, Don’t Ask does not require an object to obtain every dependency internally. Passing required context explicitly can keep the behavior deterministic and testable while still keeping the decision with its owner.
Keep commands meaningful
Moving logic behind a method does not automatically improve the design. A method such as:
order.setStatus("paid")hides a field but says little about the business operation. Callers still decide which status is valid and when to assign it.
A more meaningful command describes the transition:
order.recordPayment(payment)The order can then decide whether the payment settles the balance and whether its status should change.
This gives the boundary room to protect invariants. For example:
recordPayment(payment):
if payment.amount <= 0:
reject "payment must be positive"
if status == "cancelled":
reject "cancelled orders cannot accept payment"
payments.add(payment)
if totalPaid() >= totalDue():
status = "paid"This is still a teaching example. Production payment systems need additional concerns such as idempotency, currency handling, persistence, and concurrency. Those concerns do not change the design point: callers request a meaningful operation, while the component responsible for the invariant decides how its state should change.
Do not move every decision into the data object
Tell, Don’t Ask can be misused by placing unrelated workflow logic inside domain objects simply because those objects contain some required data.
Suppose checkout needs to decide whether to send a promotional email:
if customer.marketingConsent() and campaign.isActive(now):
mailer.sendPromotion(customer, campaign)It would be strange to move the entire operation into Customer:
customer.sendPromotionIfEligible(campaign, mailer, now)The decision spans several responsibilities: customer consent, campaign state, and message delivery. No single data object clearly owns the whole workflow.
A coordinating service can be the better owner:
promotionService.sendIfEligible(customer, campaign, now)That service may ask Customer whether marketing is permitted and ask Campaign whether it is active. Those queries are appropriate because the service owns the cross-component decision.
The goal is not to eliminate questions. The goal is to avoid placing a rule in a component that must reach through several other components to reconstruct knowledge they already own.
Distinguish local invariants from coordination
A practical way to decide where behavior belongs is to separate two kinds of rules.
Local invariants describe what must remain true for one component’s state. Examples include:
- a cancelled reservation cannot be confirmed;
- an account balance cannot be changed by an unsupported operation;
- a completed task cannot return to an in-progress state unless reopening is explicitly supported.
These rules are strong candidates for behavior on the component that owns the state.
Coordination rules decide how several independent components interact. Examples include:
- choose a shipping provider based on destination and current carrier availability;
- notify a customer after an order and a payment system both report success;
- select a promotion using customer eligibility and campaign capacity.
These rules often belong in an application service, policy object, or another coordinator. Forcing them into one participating object can increase coupling rather than reduce it.
The boundary is not always obvious. Use the rule’s inputs and consequences as evidence: if most of them concern one component’s state and invariants, that component is a likely owner. If the rule combines several peers, coordination is probably a separate responsibility.
Preserve useful queries
Command-oriented interfaces still need observability. A component that can change state but cannot report anything useful may be difficult to display, debug, test, or integrate.
For example, this interface can be reasonable:
reservation.confirm()
reservation.cancel(reason)
reservation.status()
reservation.total()confirm() and cancel() protect state transitions. status() and total() expose information that other responsibilities may legitimately need.
Trying to replace every query with commands can produce awkward APIs such as pushing presentation callbacks into domain objects. That is usually a sign that the heuristic has outrun the responsibility boundary.
Queries and commands can coexist. The design question is whether a query leaks enough decision-making knowledge that callers begin owning rules they should not own.
Consider concurrency and persistence boundaries
Moving a check and a mutation into one method improves code-level ownership, but it does not automatically make the operation atomic.
Suppose two workers load the same inventory item:
inventory.reserve(1)Each in-memory object may correctly reject a reservation when its local quantity is zero. If both workers loaded quantity 1 before either update was persisted, both may still succeed unless the storage or transaction design prevents the race.
Tell, Don’t Ask can keep the reservation rule in one conceptual place, but correctness across concurrent processes may also require mechanisms such as transactions, optimistic concurrency checks, compare-and-set operations, or another storage-level guarantee.
This distinction matters because encapsulation answers who owns the rule. It does not, by itself, answer what execution boundary makes the rule atomic.
Refactor one leaked decision at a time
When you find ask-heavy code, avoid replacing every getter at once. Start with a decision that is duplicated, frequently changed, or capable of violating an invariant.
A useful sequence is:
- Identify the rule the caller reconstructs.
- Decide which component naturally owns that rule.
- Add a command that expresses the caller’s intent.
- Move the rule behind that command.
- Keep queries that still serve legitimate external responsibilities.
- Update other callers that duplicate the same decision.
- Remove obsolete mutation methods only when no valid use remains.
For example, start with:
if booking.status() == "pending":
booking.setStatus("confirmed")Introduce:
booking.confirm()Then make confirm() enforce the transition. Once all valid callers use the operation, a generic setStatus() may no longer be necessary.
This incremental approach keeps the refactoring tied to a concrete design problem rather than to a rule about method shapes.
Common mistakes
Replacing getters with trivial wrappers
This change does little:
if account.balance() < amount:
rejectbecoming:
if account.hasBalanceBelow(amount):
rejectThe caller still decides what insufficient balance means for the operation. A more useful boundary might be:
account.withdraw(amount)where withdraw owns the invariant and reports failure in a way appropriate to the surrounding design.
Creating vague commands
Methods such as process(), handle(), or update() can hide implementation without communicating intent. Prefer names that describe the operation or transition the caller requests.
Building a giant object
Moving every related workflow into one object can produce a component that knows databases, messaging, presentation, external services, and business state. That reduces neither coupling nor complexity; it merely concentrates them.
Hiding failures
A command should not pretend an operation succeeded when it could not satisfy its contract. Depending on the context, failure may be represented by an exception, a result value, a rejected transition, or another explicit mechanism. The important point is that callers can distinguish success from failure when they need to react.
When the heuristic helps most
Tell, Don’t Ask is especially useful when callers repeatedly inspect an object’s state to decide how that same object should change. It is also valuable when a rule protects an invariant or changes often enough that duplicating it creates maintenance risk.
A simpler query-based design can be better when the data is genuinely a value being transformed by another responsibility, when objects are deliberately passive data transfer structures, or when a coordinator must combine information from several independent components.
Functional designs may express the same ownership differently. Instead of calling a method on a stateful object, a pure function can receive a value and return a new value:
updatedOrder = recordPayment(order, payment)The important principle survives the change in style: keep the rule in one well-defined place rather than making every caller reconstruct it from exposed details.
Conclusion
Tell, Don’t Ask is not a ban on queries. It is a way to notice when code asks for information mainly so it can make a decision that another component should own.
When a rule governs one component’s state or invariants, expose an operation that expresses intent and keep the decision near that rule. When a decision genuinely combines several responsibilities, let a coordinator ask the necessary questions instead of forcing the workflow into one object.
The practical test is simple: if callers keep extracting the same facts to decide how an object should behave, ask whether the decision—not merely the data—has escaped its natural owner.