Java Tell, Don't Ask
Purpose
if (account.getBalance().compareTo(amount) >= 0) account.setBalance(...) is a rule that
lives nowhere: the invariant "balance never goes below the limit" is enforced only at call
sites that remember to check, and the read–decide–write gap makes it race-prone. This skill
moves such decisions to the type that owns the invariant and has enough authoritative state
to enforce it — and, just as deliberately, refuses to move the ones that do not belong there.
Possessing data is not sufficient ownership: pricing, authorization and cross-aggregate policy
often belong to a policy object or application service. An anemic model over simple CRUD data is a
legitimate architecture; anemia is a problem only when invariants exist and no type owns
them.
Workflow
- Inspect compatibility and lifecycle. Read compiler release/toolchains, framework/binder
requirements, entity ownership, transaction/version mapping and public failure contracts.
The domain example uses Java 17; its service pattern switch/record patterns require Java 21+
without preview. Adapt to the project baseline without upgrading or adding frameworks.
- Find ask–decide–mutate sequences: getters on an object, a branch on the result,
then a setter or mutation on the same object. Each is a candidate, not a verdict.
- Name the invariant. If the branch protects a rule the object must never violate,
the decision and the mutation move into the object as one command, and the setter that
enabled the bypass is removed. If there is no invariant — the code just shovels data —
leave it; a transaction script over data is fine.
- Identify the authority and change owner. A rule spanning aggregates may belong to
a domain policy, process manager or application service; no participating entity becomes
the owner merely because it holds one input. Use
references/placement-decision.md.
- Apply an explicit command/query convention. Strict CQS makes mutating commands
return
void; pragmatic command-query separation permits a command to return its own
outcome. Queries must be observationally side-effect-free. Private, thread-safe memoization
may preserve that contract; touching externally visible state on read does not.
- Verify the supported mutation/construction paths enforce the owned rule, including
persistence/binders and concurrency boundaries. Test the domain decision and caller outcome
mapping; duplicated enforcement at independent trust boundaries may still be required.
Rules
- Put a decision with the type that owns its invariant or policy and can enforce it from
authoritative state. Callers express intent; data proximity alone does not establish ownership.
- Public queries expose information that callers can couple policy to. Keep queries needed for
boundaries, observability and legitimate decisions; remove raw mutation paths and duplicated
external derivations instead of treating every getter as a defect.
- Under strict CQS, commands return
void. If the codebase adopts the pragmatic variant, a
command may return its own result or updated representation; document that convention and do
not mix unrelated answers or externally visible read effects into it.
- A record may be a boundary DTO, a value object or an immutable domain type with behavior.
Tell-don't-ask applies according to ownership and invariant, not the
record keyword.
- A method boundary supplies no atomicity: shared in-memory instances need confinement or
synchronization, and independent persistence contexts need optimistic locking, conditional
updates or other database guards. State both boundaries; moving a method cannot replace them.
- Keep infrastructure clients and ambient mechanisms out of entities. Pass a validated policy
input when it is merely data; use a domain policy interface/value object when the behavior has
its own domain ownership. A long list of fetched inputs is evidence the decision may belong
outside the entity.
- Asking is correct at boundaries — mappers, serialisation, rendering — in queries and
reports, and in orchestration across aggregates where no single object can own the rule.
Deliverable
Name the invariant/policy authority, inputs and freshness, allowed mutation path, caller result
mapping and synchronization/commit boundary. Distinguish a mechanical move from changed failure
or validation behavior. Report checks executed and unresolved ownership evidence; do not infer
a common rule merely because two callers read the same fields.
References
- Placement decision — read when it is unclear whether
a decision belongs in the object, or the code pattern-matches ask–decide–mutate but is
correct as it stands: heuristics, false positives, and the costs of moving.
- Worked example: withdrawal against a limit — read before
moving an invariant-bearing decision out of a service: before, analysis, after, what
stays in the service, trade-offs, verification.
1---2name: java-tell-dont-ask3description: Decision ownership: the type that owns an invariant or policy makes the decision. Use when a service reads state with getters, decides, and writes state back (if (acct.getBalance() > x) acct.setBalance(...)), when the same rule is re-derived from the same getters in several places, when an invariant exists but no type enforces it, when a domain model is all getters and setters with the logic in services, or when a getter has side effects. Covers command–query separation and when asking is correct: boundaries, reporting, cross-aggregate orchestration. Does not cover the navigation chains that often carry the asking — that is java-law-of-demeter.4---56# Java Tell, Don't Ask78## Purpose910`if (account.getBalance().compareTo(amount) >= 0) account.setBalance(...)` is a rule that11lives nowhere: the invariant "balance never goes below the limit" is enforced only at call12sites that remember to check, and the read–decide–write gap makes it race-prone. This skill13moves such decisions to the type that owns the invariant and has enough authoritative state14to enforce it — and, just as deliberately, refuses to move the ones that do not belong there.15Possessing data is not sufficient ownership: pricing, authorization and cross-aggregate policy16often belong to a policy object or application service. An anemic model over simple CRUD data is a17legitimate architecture; anemia is a problem only when invariants exist and no type owns18them.1920## Workflow21220. **Inspect compatibility and lifecycle.** Read compiler release/toolchains, framework/binder23 requirements, entity ownership, transaction/version mapping and public failure contracts.24 The domain example uses Java 17; its service pattern switch/record patterns require Java 21+25 without preview. Adapt to the project baseline without upgrading or adding frameworks.261. **Find ask–decide–mutate sequences**: getters on an object, a branch on the result,27 then a setter or mutation on the same object. Each is a candidate, not a verdict.282. **Name the invariant.** If the branch protects a rule the object must never violate,29 the decision and the mutation move into the object as one command, and the setter that30 enabled the bypass is removed. If there is no invariant — the code just shovels data —31 leave it; a transaction script over data is fine.323. **Identify the authority and change owner.** A rule spanning aggregates may belong to33 a domain policy, process manager or application service; no participating entity becomes34 the owner merely because it holds one input. Use `references/placement-decision.md`.354. **Apply an explicit command/query convention.** Strict CQS makes mutating commands36 return `void`; pragmatic command-query separation permits a command to return its own37 outcome. Queries must be observationally side-effect-free. Private, thread-safe memoization38 may preserve that contract; touching externally visible state on read does not.395. **Verify** the supported mutation/construction paths enforce the owned rule, including40 persistence/binders and concurrency boundaries. Test the domain decision and caller outcome41 mapping; duplicated enforcement at independent trust boundaries may still be required.4243## Rules4445- Put a decision with the type that owns its invariant or policy and can enforce it from46 authoritative state. Callers express intent; data proximity alone does not establish ownership.47- Public queries expose information that callers can couple policy to. Keep queries needed for48 boundaries, observability and legitimate decisions; remove raw mutation paths and duplicated49 external derivations instead of treating every getter as a defect.50- Under strict CQS, commands return `void`. If the codebase adopts the pragmatic variant, a51 command may return its own result or updated representation; document that convention and do52 not mix unrelated answers or externally visible read effects into it.53- A record may be a boundary DTO, a value object or an immutable domain type with behavior.54 Tell-don't-ask applies according to ownership and invariant, not the `record` keyword.55- A method boundary supplies no atomicity: shared in-memory instances need confinement or56 synchronization, and independent persistence contexts need optimistic locking, conditional57 updates or other database guards. State both boundaries; moving a method cannot replace them.58- Keep infrastructure clients and ambient mechanisms out of entities. Pass a validated policy59 input when it is merely data; use a domain policy interface/value object when the behavior has60 its own domain ownership. A long list of fetched inputs is evidence the decision may belong61 outside the entity.62- Asking is correct at boundaries — mappers, serialisation, rendering — in queries and63 reports, and in orchestration across aggregates where no single object can own the rule.6465## Deliverable6667Name the invariant/policy authority, inputs and freshness, allowed mutation path, caller result68mapping and synchronization/commit boundary. Distinguish a mechanical move from changed failure69or validation behavior. Report checks executed and unresolved ownership evidence; do not infer70a common rule merely because two callers read the same fields.7172## References7374- [Placement decision](references/placement-decision.md) — read when it is unclear whether75 a decision belongs in the object, or the code pattern-matches ask–decide–mutate but is76 correct as it stands: heuristics, false positives, and the costs of moving.77- [Worked example: withdrawal against a limit](references/worked-example.md) — read before78 moving an invariant-bearing decision out of a service: before, analysis, after, what79 stays in the service, trade-offs, verification.