Tell, Don’t Ask: Keep Decisions with the Data

A checkout service reads an order’s status, total, and payment state, decides whether cancellation is allowed, and then changes the order. Later, a support tool needs the same operation and copies most of that decision logic. The two callers eventually disagree about one rule.

Tell, Don’t Ask is a software design principle that helps prevent this kind of drift. Instead of asking an object for internal state so another object can make a decision about it, prefer telling the object what outcome you want and letting the object enforce the rules that belong to its state.

This article explains how to recognize the problem, move the right decisions closer to their data, and avoid turning the principle into a rule against ordinary queries.

The mental model: decisions need an owner

Suppose an order can be cancelled only while it is pending and before a payment has been captured. A caller-oriented design might expose the facts and leave the decision outside the order:

if order.status == PENDING and not order.paymentCaptured:
    order.status = CANCELLED

The code is easy to read in isolation. The problem appears when more callers need cancellation. Each caller must know the same combination of facts and the same transition rule.

A Tell, Don’t Ask design gives that decision an owner:

order.cancel()

Inside the order:

cancel():
    if status != PENDING:
        return error("order is not pending")

    if paymentCaptured:
        return error("payment has already been captured")

    status = CANCELLED
    return success

The order still contains the same state. What changed is where the decision lives. Callers request cancellation; the order decides whether cancellation is valid and performs the state change if it is.

That boundary matters because the rule and the state now change together. If the cancellation policy later depends on another order field, there is one obvious place to update the decision.

Asking for data is not the problem by itself

The phrase “Tell, Don’t Ask” can sound as if getters or queries are inherently bad. They aren’t.

Software frequently needs to read data for presentation, reporting, serialization, logging, or calculations owned elsewhere. A screen that displays order.total is not violating a useful design boundary merely because it asks for a value.

The warning sign is a particular sequence:

  1. A caller asks an object for several pieces of its state.
  2. The caller interprets those values using rules that conceptually belong to that object.
  3. The caller then tells the object, or some other component, how to change as a result.

The caller has effectively taken ownership of another object’s decision.

A useful question during code review is: if this rule changes, which abstraction should be responsible for knowing that? If the answer is “the order,” but the rule is implemented in three services that inspect the order, the design boundary is probably in the wrong place.

Keep invariants next to the operations that can break them

An invariant is a condition that must remain true for an object or subsystem to be valid. For the order above, one invariant might be that a captured order cannot transition directly to CANCELLED.

If callers can set status freely, every caller must preserve that invariant:

order.status = CANCELLED

The order cannot distinguish a valid transition from an invalid one because the decision happens outside it.

Giving the order explicit operations changes the interface:

order.cancel()
order.markPaid(paymentId)
order.ship(shipmentId)

Now each operation can check the conditions relevant to its transition. The public interface describes meaningful actions rather than exposing a bag of fields for callers to manipulate.

This does not guarantee correctness on its own. Concurrency, persistence, and external side effects can still introduce failures. The narrower claim is that code using the object has fewer ways to create an invalid in-memory transition because the transition rules are centralized behind meaningful operations.

Move behavior, not unrelated responsibilities

Tell, Don’t Ask becomes harmful when it is interpreted as “put everything inside the domain object.” An order should not necessarily send email, charge a card, update an analytics system, and write directly to a database just because those actions follow an order decision.

Separate the decision from the orchestration.

For example, cancellation may require a refund when payment has already been authorized but not settled. The order can own the decision about what cancellation means without knowing how a payment provider works:

result = order.requestCancellation()

if result.requiresRefund:
    paymentGateway.refund(result.paymentId)

orderRepository.save(order)

Here the order determines the valid business transition and returns information about its consequence. An application service coordinates external systems.

The exact boundary depends on the domain. The principle is not that behavior must live in a class with private fields. The principle is that a decision should live with the abstraction that has the knowledge required to make it, while infrastructure coordination stays with components responsible for orchestration.

Watch for feature envy

A common clue is code that spends more time examining another object than using its own state. This is often called feature envy.

Consider a shipping service:

shippingCost(order):
    if order.customerTier == "premium" and order.total >= 50:
        return 0
    return order.packageWeight * standardRate

Whether this logic should move depends on what the rule represents. If free shipping is an order-level commercial policy, the order or a dedicated shipping-policy abstraction may be a better owner. If the calculation depends on carrier zones, negotiated rates, and external pricing tables, moving the entire calculation into Order would mix unrelated knowledge.

Tell, Don’t Ask helps identify misplaced decisions; it does not tell you automatically which object should receive them.

When a decision draws on knowledge from several concepts, a separate policy object can be clearer:

cost = shippingPolicy.calculate(order, destination)

The policy receives the information it legitimately needs and owns the shipping rule. That is different from an arbitrary caller reconstructing the rule wherever shipping happens to be needed.

Avoid turning commands into hidden surprises

A method named cancel() communicates a state transition. A method named customerName() communicates a query. Problems arise when a command hides effects that callers cannot reasonably predict.

For example, if order.cancel() silently performs network calls, sends notifications, and blocks until several remote systems respond, the method may satisfy a superficial reading of Tell, Don’t Ask while making the system harder to understand and operate.

Prefer interfaces that make significant boundaries visible. A domain operation can produce a result or event that the application layer handles explicitly:

cancellation = order.cancel()
repository.save(order)
notifications.sendCancellation(cancellation)

This makes failure handling clearer. A domain transition and a failed notification are different events with different recovery options. Hiding both behind one apparently local method can obscure that distinction.

Do not remove useful queries just to satisfy the principle

Some designs react to Tell, Don’t Ask by replacing every query with a command. That usually produces awkward APIs.

A pricing component may legitimately need an order subtotal. A renderer may legitimately need a display name. A monitoring component may legitimately ask for current state. Those consumers are using information for responsibilities they own; they are not necessarily stealing the object’s business decisions.

There is also a cost to adding behavior methods. Each method expands an abstraction’s interface and creates another concept maintainers must understand. If a value is simple data with no invariant or behavior attached to it, wrapping every read in a command adds ceremony without improving the design.

Use the principle where it reduces duplicated knowledge or protects meaningful rules. Leave straightforward data access straightforward when there is no decision to relocate.

Refactor toward Tell, Don’t Ask in small steps

When you find scattered decision logic, start with one rule rather than redesigning the whole model.

First, identify the state the caller reads and the decision it makes from that state. Check whether other callers repeat the same reasoning. Then introduce a method or policy that expresses the decision in domain language.

For the cancellation example, a safe progression could be:

order.canCancel()

followed by:

order.cancel()

canCancel() centralizes the predicate, but callers can still create a check-then-act pattern: they ask whether cancellation is allowed and then separately perform the transition. If the state can change between those operations, or if callers can bypass the check, the invariant is still exposed.

Moving to cancel() combines validation and transition behind one operation. The caller handles success or failure instead of reproducing the rule.

In concurrent systems, this in-memory method is only part of the solution. If two processes can modify the same persisted order, the storage boundary may also need optimistic concurrency control, locking, or another mechanism appropriate to the system. Tell, Don’t Ask organizes decision ownership; it does not provide transaction isolation.

When Tell, Don’t Ask pays off

The principle is most useful when callers repeatedly inspect state to enforce the same business rule, when valid state transitions depend on several fields, or when changes to one rule regularly require edits across unrelated services.

It is less useful as a blanket rule for immutable data transfer objects, reporting models, simple configuration values, or code whose responsibility genuinely is to combine information from several independent sources.

The practical test is change. If a rule changes, can you point to one clear owner for that decision? If callers must know the internals of another abstraction to keep the system valid, move the decision toward the knowledge it depends on. If a caller is merely reading data for a responsibility of its own, asking is often exactly the right thing to do.

Give the next rule a clear home

The next time you see code that fetches several fields, interprets them, and then mutates the object it just inspected, look at the decision rather than the syntax. Ask which abstraction should know that rule and which component should only request the outcome.

A good Tell, Don’t Ask refactoring usually makes one thing boring: callers stop knowing the details of a decision they never needed to own. That smaller knowledge surface is what makes the design easier to change without letting the same rule drift across the codebase.