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

A class can expose perfectly reasonable getters and still make a codebase harder to change. The trouble appears when callers fetch several values, interpret them, make a business decision, and then tell the object how to update itself. The data lives in one place, but the rule that gives the data meaning lives somewhere else.

Tell, Don’t Ask is a design guideline for reducing that split. Instead of asking an object for internal state so a caller can decide what should happen, prefer telling the object the meaningful operation you want performed. The object can then apply the rules that belong with its state.

This article shows how to recognize the pattern, refactor it in small steps, and avoid turning the guideline into a ban on queries or getters.

The mental model: send intent instead of reconstructing a decision

Consider a simplified bank account:

if account.balance() >= amount:
    account.setBalance(account.balance() - amount)
    receipt.recordWithdrawal(amount)

The caller asks for balance, interprets that value, checks a rule, calculates the new value, and writes it back.

That creates two responsibilities outside Account:

rule:       balance must cover the withdrawal
transition: new balance = old balance - amount

If another caller needs to withdraw money, it can copy those responsibilities. The copies may later drift. One caller might add an overdraft rule while another keeps the old check.

Tell, Don’t Ask moves the decision to the object that owns the relevant state:

result = account.withdraw(amount)

if result.accepted:
    receipt.recordWithdrawal(amount)

Now Account owns the rule and the state transition:

class Account:
    withdraw(amount):
        if amount <= 0:
            return Rejected("amount must be positive")

        if amount > balance:
            return Rejected("insufficient funds")

        balance = balance - amount
        return Accepted()

The caller still coordinates the larger workflow. It just stops reconstructing an account rule from account data.

A useful distinction is:

ask for information when the caller needs information
send intent when the object owns the decision

The guideline is about responsibility, not syntax. A method call with a verb can still expose a poor design, and a getter can be completely appropriate.

Spot the decision that escaped its owner

The strongest signal is not the presence of getters. It is a repeated sequence in which a caller obtains state, makes a decision that depends on that state, then changes the same object.

For example:

if order.status() == "pending" and order.total() > 0:
    order.setStatus("confirmed")

The caller knows the conditions under which an order may become confirmed. If several application services perform this transition, each one must know the same rule.

A more focused interface is:

order.confirm()

with the rule inside Order:

class Order:
    confirm():
        if status != "pending":
            return Rejected("order is not pending")

        if total <= 0:
            return Rejected("order has no payable total")

        status = "confirmed"
        return Accepted()

The change does more than shorten the caller. It gives the rule one natural home. A future change to confirmation eligibility can be made where the transition is implemented instead of at every caller that happens to know the required fields.

Before moving logic, check that the object really owns the decision. A rule involving an order, a payment provider, inventory, and a shipping service may belong in an application service or domain service because no single object has enough authority or information to decide alone.

Preserve useful queries

A common mistake is interpreting Tell, Don’t Ask as “objects should never expose state.” That is too strict.

Queries are useful when another component genuinely needs information. A report may need an order total. A user interface may need a display name. A serializer may need values to produce an external representation. Those callers consume information; they are not taking over the object’s business decisions.

Compare these two uses:

display(order.total())

and:

if order.total() > 1000:
    order.setApprovalRequired(true)

The first reads data for presentation. The second uses the data to enforce a rule and mutate the same object. If the approval threshold is part of the order’s behavior, that second sequence is a candidate for a meaningful operation such as:

order.evaluateApprovalRequirement()

or, if the threshold is supplied by policy:

order.applyApprovalPolicy(policy)

The exact method depends on the model. The design goal is not to hide every value. It is to avoid making callers assemble rules that belong behind an object’s behavioral boundary.

Move one complete rule, not half of it

A partial refactoring can leave responsibility just as scattered as before.

Suppose a subscription may be renewed only while active:

if subscription.isActive():
    subscription.renewFor(months)

Moving only the mutation does not solve much if renewFor assumes the caller already checked the condition. Any new caller can forget the guard.

A stronger boundary makes the operation enforce its own precondition:

result = subscription.renewFor(months)
class Subscription:
    renewFor(months):
        if status != "active":
            return Rejected("subscription is not active")

        if months <= 0:
            return Rejected("duration must be positive")

        endDate = endDate.plusMonths(months)
        return Accepted()

The object now protects the transition whenever that operation is used.

This does not mean every method must validate every possible condition. The object should enforce conditions required for its own valid state and behavior. Environmental concerns such as authorization, network availability, or a transaction boundary may belong elsewhere.

Return outcomes without exposing the decision again

Commands can fail for expected business reasons. Hiding the rule inside an object does not remove the caller’s need to react to the outcome.

A useful command can return a small result:

result = account.withdraw(amount)

if result.accepted:
    ledger.recordWithdrawal(account.id(), amount)
else:
    response.show(result.reason)

The caller coordinates what happens after the account decides whether the withdrawal is valid. It does not repeat the balance rule.

There are several reasonable ways to represent outcomes: result values, domain-specific errors, exceptions, or another mechanism supported by the language and codebase. The key boundary is more general:

object decides whether its operation is valid
caller decides what the wider workflow does with that outcome

Avoid returning so much internal state that the caller has to repeat the original decision. A result such as Rejected("insufficient funds") preserves the boundary better than returning the balance and expecting the caller to infer the rejection rule again.

Keep coordination outside the entity

Tell, Don’t Ask can be pushed too far. An object that owns local state should not automatically become responsible for email, persistence, logging, payments, inventory, and every other action triggered by a state change.

Consider order cancellation. The order can decide whether its own status permits cancellation:

result = order.cancel()

An application service can then coordinate external effects:

result = order.cancel()

if result.accepted:
    repository.save(order)
    paymentService.requestRefund(order.paymentId())
    notifier.sendCancellation(order.customerId())

This separation keeps two kinds of responsibility clear.

Order owns its valid state transition. The application service owns orchestration across system boundaries.

Putting all of those collaborators inside Order would couple a domain object to infrastructure and make a local rule harder to reason about. Tell, Don’t Ask is not a request to move every line into the object with the data.

Watch for behavior that belongs to a policy instead

Sometimes a caller asks for data because the decision depends on rules that vary independently from the object.

Suppose shipping cost depends on destination, package weight, service level, and a pricing policy that changes by market. Putting the entire pricing algorithm inside Package may give the package responsibility for commercial policy that has its own lifecycle.

A better shape can be:

quote = shippingPolicy.quote(package, destination, serviceLevel)

Here, Package can expose the information needed by the policy because the policy legitimately owns the decision.

This is a useful boundary test: ask which concept would change when the rule changes. If a change to account withdrawal rules naturally belongs with Account, move the rule there. If a change to pricing policy should not require changing Package, keep the policy separate.

The goal is cohesive responsibility, not maximum encapsulation at any cost.

Refactor safely in small steps

When a codebase already has several callers performing the same ask-decide-mutate sequence, move the behavior incrementally.

Start by identifying one complete decision and its state transition. Add a method that performs both. Keep its inputs explicit. Then migrate one caller and verify that observable behavior remains the same. Once all callers use the new operation, remove setters or queries that no longer serve a legitimate purpose.

For the order example, the sequence might be:

1. add order.confirm()
2. move confirmation guards into confirm()
3. return an explicit outcome
4. migrate each caller
5. remove setStatus() if no valid caller still needs it

Tests should focus on behavior at the new boundary:

pending order with positive total -> confirmation accepted
already confirmed order          -> confirmation rejected
zero-total order                  -> confirmation rejected

Those cases describe the contract of confirm. Tests do not need to reproduce the internal condition structure.

If existing behavior is poorly understood, preserve it first with characterization tests before changing responsibility. A design improvement is not useful if it silently changes established behavior that the system still relies on.

Common failure modes

The first failure mode is creating vague command methods such as process(), handle(), or update(). A behavioral interface is easier to use when operations express domain intent: confirm, cancel, reserve, withdraw, or renewFor.

Another failure mode is hiding information that callers genuinely need. Removing every getter can force reporting, serialization, and presentation code through awkward command-shaped methods. Keep queries that serve clear informational purposes.

A third failure mode is moving cross-object orchestration into one entity merely because that entity appears central to the workflow. If an operation coordinates several independently owned resources, an application service may remain the right place for that coordination.

Also watch for commands with hidden environmental dependencies. If order.confirm() silently reads global configuration, contacts a remote service, and writes to storage, its compact interface conceals significant effects. A small method name is not a substitute for a clear dependency boundary.

When Tell, Don’t Ask is a good fit

The guideline is especially useful when callers repeatedly inspect an object’s state to enforce the same rule, when public setters allow invalid transitions, or when a state change has a clear domain name that belongs to the object.

It offers less value for simple data transfer structures, immutable records used mainly for communication, reporting models, or objects whose purpose is to expose information. For those types, direct queries may be the intended interface.

It also has limits in procedural or functional designs where behavior and data are deliberately separated. The broader principle still applies: place a decision in one coherent module instead of scattering copies of the rule across callers. The object-oriented phrasing is one way to express that principle, not a universal requirement for software structure.

A practical next step

When reviewing code, look for a caller that reads several values from one object, makes a domain decision, and then writes back to that object. Name the decision in plain business terms. If the object has enough information and authority to enforce that decision, give it an operation with that name and move the complete rule behind the operation.

Keep genuine queries. Keep orchestration at the appropriate boundary. Move only the behavior that forms a cohesive responsibility.

That is the useful form of Tell, Don’t Ask: callers express intent, objects protect the decisions that belong with their state, and business rules have fewer places to drift apart.