skip to content

Following Tell, Don't Ask tends to produce objects whose methods return little or nothing. How do you keep such designs testable and observable without re-exposing internal state?

level: seniorimportance: should knowfreq 22%

answer

  1. void everywhere -> tests reach for getters
  2. return Result/outcome, not internals
  3. emit domain events, assert on them
  4. tell a recording collaborator (port)
  5. mocks only for real outbound behavior

basics

~10 s

Have the object report outcomes instead of state: return a result type, emit a domain event, or call a collaborator you can substitute in tests. Assert on behavior and outcomes rather than on fields.

solid answer

~50 s

The risk is real: if every method is a void command, tests either add getters back (undoing the encapsulation) or verify via mocks, which couples tests to interactions. Better options: (1) return an outcome/Result type - `withdraw(amount) -> Ok | InsufficientFunds` - which is still telling, since the object made the decision; (2) return or record domain events and assert on the emitted events; (3) make the object immutable and return the new state, so the assertion is on a returned value, not a field read; (4) expose a small number of intention-revealing domain queries (`isSubmitted()`, `balance()`) that are part of the contract rather than a mirror of fields; (5) inject a collaborator (port, listener, presenter) that the object tells, and assert on a test double. Prefer state-or-outcome assertions where cheap, and interaction assertions only for genuine collaborations at boundaries, because mock-heavy tests break on refactorings that preserve behavior.

code

pseudocode · 8 lines
pseudocode
// Telling, but observable: an outcome the test can assert on
result = account.withdraw(50)
assert result == InsufficientFunds(available = 20)

// Or: the object announces, a recording double listens
recorder = RecordingListener()
checkout.process(cart, recorder)
assert recorder.received == [PaymentDeclined(reason = EXPIRED_CARD)]

go deeper

for a junior

Say the object should report an outcome (a result value or event) instead of exposing fields, and that tests assert on that.

for a middle

List the concrete techniques - result types, events, immutability, intention-revealing queries, injected listeners - and pick one per situation.

for a senior

Discuss state versus interaction verification, over-specification, and the heuristic that behavior-preserving refactorings must not break tests.

for a principal

Tie it to boundary design: outbound ports are where mock verification is legitimate, domain events double as the integration and observability mechanism, and untestable commands usually indicate a wrong aggregate boundary.

## Why the tension exists Tell-style methods often have this shape: `order.submit(clock)` - side effects inside, nothing returned. A test then asks: *how do I know it worked?* The two lazy answers are both bad: - **Add a getter for the field** (`getStatus()`) purely for the test. That re-publishes the representation and undoes the reason for telling in the first place. - **Verify with mocks on every internal call.** Interaction-based tests couple to *how* the work is done; harmless refactorings (extracting a method, reordering calls) break them. This is over-specification, a known cause of brittle suites. ## Techniques that keep both properties **1. Outcome / Result types.** `reserve(sku, n) -> Reserved(id) | OutOfStock(available)`. The owner still made the decision (no check-then-act race, no duplicated rule); the caller and the test get a first-class answer to branch and assert on. Works well with sealed/union types or an explicit outcome enum plus payload. **2. Domain events as the return value or an emitted record.** `order.submit(clock) -> OrderSubmitted(orderId, at)` or `order.recordedEvents()`. Assertions read like the domain ("submitting an empty order emits nothing and yields RejectedEmpty"). This is the standard technique in event-sourced and DDD-flavoured designs, and it doubles as the integration mechanism. **3. Immutability.** For value objects, `state.apply(command)` returns the new state; the test compares returned values. Nothing is hidden because nothing is mutated - the strongest form, but not always practical for long-lived entities. **4. Deliberate domain queries.** A small, stable set of questions the domain genuinely answers (`isSubmitted()`, `availableCredit()`) is fine - they are part of the published contract, chosen by the designer, not a getter per field. The test asserts through the same vocabulary the production code uses, so representation changes do not break it. **5. Tell a collaborator (ports / listeners / presenters).** `checkout.process(cart, resultListener)` where the listener is `PaymentAccepted`/`PaymentDeclined`. In tests, pass a recording double and assert on what it was told. This is the "tell, don't ask" way to observe: the object announces, it is not interrogated. It also matches hexagonal/ports-and-adapters style, where the double stands in for an outbound port. ## Choosing between state, outcome and interaction assertions - **Outcome/returned value**: default. Cheapest, most refactor-proof. - **Domain-query state**: fine when the query is real contract; avoid asserting on fields. - **Interaction (mock verification)**: reserve for genuine outbound collaborations - the email was sent, the payment gateway was charged, the event was published. These *are* the behavior; asserting them is not over-specification. A useful test: if a refactoring that preserves observable behavior breaks the test, the test was asserting on implementation. ## Edge cases and cautions - **Test-only accessors** (package-private getters, `@VisibleForTesting`) are a pragmatic compromise on legacy code, but they leak in practice and tend to acquire production callers. - **Void commands with no observable effect anywhere** are untestable by construction - and usually indicate a missing outcome, missing event, or missing collaborator. - **Deep object graphs** make outcome types awkward; often the aggregate boundary is wrong and a smaller unit should own the decision. - **Over-returning** slides back into ask style if the caller uses the returned data to redo the owner's rules. Return an *answer* (Rejected, InsufficientFunds), not the *inputs* the owner used to decide. - **Command-Query Separation** friction: returning an outcome from a mutating command technically mixes command and query. This is a widely accepted, deliberate relaxation - the alternative (mutate, then ask what happened) recreates a race and a second source of truth.

  • Doesn't returning an outcome from a mutating method violate Command-Query Separation?
    Strictly yes, and it is a deliberate, widely accepted relaxation - the same reason `pop()` and `compareAndSet()` exist. The alternative, mutate and then query what happened, splits one decision into two steps and reopens the race and the duplicated-rule problem the principle is trying to close.
  • When is verifying with a mock the right assertion rather than a smell?
    When the interaction is the observable behavior at a boundary: an email dispatched, a payment gateway charged, an event published to a broker. Mocking internal helper calls of the unit under test is over-specification and breaks on behavior-preserving refactorings.

A good bank teller doesn't let you count the vault to confirm your withdrawal - they hand you a receipt. The receipt is an outcome, it proves what happened, and the vault's layout stays private.

saying these in an interview costs you the question

  • Adding getters 'just for the tests' and treating that as neutral
  • Verifying every internal call with mocks, producing tests that break on harmless refactorings
  • Claiming Tell, Don't Ask requires void methods
  • Returning the raw inputs used for the decision, letting callers recompute the rule - ask style through the back door
  • Believing untestable void commands are acceptable rather than a signal of a missing outcome, event or collaborator

context