skip to content

How does Tell, Don't Ask translate to service and API boundaries in a distributed system, and where does it stop applying?

level: principalimportance: nice to knowfreq 16%

answer

  1. intent API, not CRUD over owner's state
  2. remote check-then-act cannot be atomic
  3. idempotency key + ETag/If-Match
  4. payloads/events/read models stay anemic
  5. one invariant, one owner; else eventual consistency

basics

~20 s

Across services, telling means sending a command or intent ("reserve these seats") instead of pulling another service's data and deciding for it. That avoids chatty calls and stale decisions - but payloads themselves are still plain data.

solid answer

~50 s

At the boundary, the ask-style equivalent is a caller fetching another service's state and applying that service's rules - which duplicates logic across teams, produces chatty round trips, and decides on data that may already be stale by the time it lands. Telling means intent-shaped APIs: `POST /reservations` returning Confirmed or SoldOut, not `GET /inventory` plus client-side arithmetic plus `PUT`. Because only the owner can make the check and the effect atomic, this is also the correctness argument - remote check-then-act has an unavoidable race, mitigated with idempotency keys, optimistic concurrency (ETag/If-Match, version fields) or conditional writes. It stops applying at the data plane: payloads, events and read models are deliberately anemic; reporting, search and BFF-style aggregation legitimately pull data from many owners; and a service must not be told to do things outside its bounded context, or you get a distributed god service and hidden coupling. The design question becomes ownership: which service owns the invariant, and does the API express intent or expose state?

code

http · 10 lines
http
# Ask-shaped: racy, duplicates the owner's rule in the client
GET /inventory/123        -> {"available": 4}
PUT /inventory/123        {"available": 2}

# Tell-shaped: intent + idempotency + owner-produced outcome
POST /reservations
Idempotency-Key: 7f3c-...
{"sku": 123, "qty": 2}
-> 201 {"status":"RESERVED","id":"r-88"}
-> 409 {"code":"OUT_OF_STOCK","available":1}

go deeper

for a junior

Say a service API should accept intents like 'reserve seats' rather than exposing raw state for clients to modify.

for a middle

Add the concrete costs of the ask shape: duplicated rules across clients, chattiness, staleness; and name idempotency keys.

for a senior

Lead with atomicity and rule ownership, and cover ETag/If-Match, outcome-rich error responses, and sagas for multi-service workflows.

for a principal

Frame everything as invariant ownership and boundary placement; discuss when event-driven replication plus eventual consistency beats synchronous telling, and how an unownable invariant signals wrong boundaries.

## Lifting the principle a level Inside a process, the unit is an object. Across services the unit is a **bounded context** - a service that owns some data and the rules over it. The same question applies: *who decides?* **Ask-shaped boundary** ``` GET /inventory/sku/123 -> { available: 4 } (client checks 4 >= 2, applies its own rules) PUT /inventory/sku/123 -> { available: 2 } ``` **Tell-shaped boundary** ``` POST /reservations { sku:123, qty:2, idempotencyKey:... } -> 201 Reserved | 409 OutOfStock(available:1) ``` ## Why the tell shape usually wins remotely 1. **Atomicity.** Remote check-then-act cannot be atomic: the world changes between the GET and the PUT. Only the owner can wrap the decision and the write in one transaction, lock, or conditional update. This is the same race as in-process, with a far larger window. 2. **Rule ownership.** If three clients each implement "can this be reserved?", the rule is now versioned across three deploy pipelines and three teams. Changing it becomes a coordinated release. 3. **Chattiness and latency.** Ask style produces N+1 round trips; a single intent call collapses them and lets the owner batch internally. 4. **Stale data.** A decision made on a snapshot is a decision made in the past. Intent APIs let the owner evaluate against current state. 5. **Security and audit.** Enforcement at the owner means one authorization point and one audit record, instead of trusting each client. ## Mechanisms that make remote telling work - **Intent-named endpoints/messages**: `Reserve`, `Cancel`, `ApproveLoan` - not CRUD over the owner's tables. - **Idempotency keys** so retries of a command are safe (at-least-once delivery is the norm). - **Optimistic concurrency** when a read *is* needed: ETag/`If-Match`, or a version field, so a stale decision is rejected rather than silently applied. - **Outcome-rich responses**: `409 OutOfStock` with structured detail beats a bare failure - the caller branches on an answer the owner produced. - **Sagas / process managers** for multi-service workflows: a coordinator *tells* each participant and compensates on failure, instead of any participant reading others' state. - **Asynchronous commands and events**: `OrderPlaced` lets owners react on their own terms; note the distinction - a *command* is directed and may be rejected, an *event* is a fact broadcast to whoever cares. ## Where it stops applying - **Data plane is data.** Request/response bodies, events, and messages are anemic by definition; putting behavior in a wire schema couples every consumer to it. - **Read models / reporting / search / BFF aggregation.** Aggregating across owners is a legitimate query workload; that is the whole point of CQRS's read side and of backend-for-frontend layers. Do not force these through command APIs. - **Cross-context orchestration** must live in a coordinator, not be imposed on a participant - telling a service to do something outside its bounded context creates a distributed god service, semantic coupling, and shared-ownership disputes. - **Autonomy vs coupling.** Synchronous telling makes the caller's availability depend on the owner's. Sometimes the better answer is neither ask nor sync tell: replicate the data the caller needs via events and let it decide locally, accepting eventual consistency. That is deliberately "asking" a local copy, and it is often the right architectural trade. ## The real question: invariant ownership The useful principal-level framing: for each invariant, name exactly one owner; put the decision there; shape the API as intent; and treat any client-side re-implementation of that rule as a design defect, not a convenience. Where an invariant genuinely spans services, you have either drawn the boundary wrong (merge them, or move the invariant into one aggregate) or you must accept eventual consistency with compensation - not distributed check-then-act. ## Failure modes to name in an interview - **Anemic services**: CRUD APIs over another team's tables, with orchestration logic in the client - the distributed anemic domain model. - **Distributed feature envy**: a service that mostly reads another service's data to compute things; usually the boundary is misplaced. - **Chatty aggregation on the write path** (as opposed to a purpose-built read model). - **Retry storms** from non-idempotent commands.

  • If a caller must read remote state before acting, how do you avoid the check-then-act race?
    Make the write conditional on what was read: optimistic concurrency with ETag plus If-Match, or a version/expected-state field the owner validates, so a stale decision is rejected with 409/412 rather than applied. Combine with an idempotency key so safe retries do not double-apply, and keep the rule's enforcement in the owner regardless.
  • When is replicating another service's data - so the caller can decide locally - better than telling that service?
    When caller autonomy and latency matter more than immediate consistency: replicate via events into a local read model and decide there, accepting eventual consistency and a compensation path. It is a deliberate trade, valid only for decisions that tolerate staleness - never for the invariant the other service owns, such as final stock allocation or funds capture.

You don't read a hotel's booking spreadsheet and cross out a room yourself - you send a reservation request and get a confirmation or a rejection. The hotel is the only party that can check and commit in one step, and its rules can change without you learning them.

saying these in an interview costs you the question

  • Claiming payloads and events should contain behavior because 'objects should be rich'
  • Ignoring idempotency and concurrency control while replacing GET-then-PUT with a command
  • Treating reporting, search and BFF aggregation as violations that must become command APIs
  • Telling a service to perform work outside its bounded context, creating a distributed god service
  • Assuming synchronous intent calls are always better, ignoring the availability coupling they introduce
  • Solving a cross-service invariant with distributed check-then-act instead of redrawing the boundary or accepting compensation

context