Tell objects what to do instead of querying state and deciding outside; anaemic versus rich models; invariants; where querying is fine (CQRS reads, DTOs).
Tell, Don't Ask means you tell an object what you want done, and the object decides how, using its own data. Instead of a service doing if (order.Status == Pending && !order.IsPaid) order.Status = Cancelled;, you call order.Cancel(), and the order enforces the rule. That keeps invariants in one place, removes duplicated checks across services and moves you from an anaemic model towards a rich one. It applies to behaviour; for reading data to display or report, querying is exactly right.
"Tell, Don't Ask means you tell an object what you want done and let it decide how, using its own data, instead of asking for its state and making the decision outside. The ask style looks like
if (order.Status == Pending && !order.IsShipped) order.Status = Cancelled;sitting in a service. Every caller has to remember every rule, and one that forgets corrupts state. The tell style isorder.Cancel(), and the order enforces its own rules: business logic lives with the data it protects. That keeps invariants in one place, removes duplicated checks across services, makes rules unit-testable on the entity itself, and moves a codebase from an anaemic model (property bags plus services) towards a rich model. In C# I enforce it with private setters, methods named after domain actions (Approve,Ship,Cancel,Withdraw) and a result type or domain exception when an action isn't allowed. EF Core maps private setters and backing fields happily. It applies to behaviour: for reading data to display, report or return from an API, asking is exactly right."
| Ask (logic outside) | Tell (logic inside) |
|---|---|
if (account.Balance >= amount && !account.IsFrozen) account.Balance -= amount; |
account.Withdraw(amount); |
if (order.Status == Pending) order.Status = Cancelled; |
order.Cancel(); |
if (subscription.EndsAt < now) subscription.IsActive = false; |
subscription.ExpireIfDue(now); |
| Rule duplicated in every caller | Rule in one place |
| Invariants can be bypassed via setters | State changes only through methods |
| Benefit | Why |
|---|---|
| Invariants enforced once | Only the object changes its own state |
| No duplicated rules | Services call the method instead of re-implementing checks |
| Easy unit tests | Test Order transitions without services, databases or mocks |
| Safer refactoring | Internal representation can change behind the methods |
| Clearer language | Code speaks the domain (Cancel, Ship) instead of setting flags |
| Fewer Demeter violations | Callers stop navigating state |
| Situation | Why asking is fine |
|---|---|
| UI screens, reports, API responses | They need data, not behaviour |
| Read models / CQRS queries | Purpose-built for querying |
| DTOs across boundaries | Plain data |
| Decisions spanning several aggregates | A domain service asks each, then tells each |
| Simple CRUD with no rules | A property bag is honest when there's no behaviour to protect |
| Technique | Purpose |
|---|---|
private set / backing fields |
No external state changes |
Read-only collections exposed (IReadOnlyList<OrderLine>) |
No external Add bypassing rules |
Action methods (AddLine, Ship, Cancel) |
The only way to change state |
| Result types or domain exceptions | Report invalid actions explicitly |
| Factory methods / constructors with validation | Objects start valid |
| Command-query separation | Commands change state; queries return data |
In our [order platform], [five] services could cancel an order (customer self-service, support tool, fraud job, payment-failure handler and an admin API), each with its own status checks, and Order.Status had a public setter. The fraud job's check forgot that shipped orders can't be cancelled, and [hundreds] of shipped orders were cancelled and refunded before anyone noticed. I moved the rule into Order.Cancel() (returning a failure result for shipped orders, idempotent for already-cancelled ones), made the setter private, and changed all five services to call the method. EF Core kept mapping the private setter without changes. We added unit tests for every state transition and a review rule against public setters on aggregates; there's now exactly one way to cancel, and it can't skip the check.
Checks domain modelling and encapsulation skills.
Green flags
Red flags
| Idea | Source | Relationship |
|---|---|---|
| Tell, Don't Ask | Andy Hunt & Dave Thomas (Pragmatic Programmers); Alec Sharp | Put behaviour with data |
| Command-Query Separation | Bertrand Meyer | Commands change state, queries return data |
| Law of Demeter | Holland & Lieberherr | Telling the nearest object removes navigation chains |
| Rich domain model / aggregates | Eric Evans (DDD) | Aggregates enforce their invariants through methods |
| Anaemic domain model (anti-pattern) | Martin Fowler | Data-only entities with logic in services |
MarkPaid() Ship()
Pending ------------> Paid ----------> Shipped
| |
| Cancel() | Cancel()
v v
Cancelled <------------+
(Shipped → Cancel() = failure)
Each transition is a method; illegal transitions are rejected in one place. Tests cover the table of transitions directly.
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyList<OrderLine> Lines => _lines;
public void AddLine(Sku sku, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Pending) throw new InvalidOperationException("Order is locked");
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
_lines.Add(new OrderLine(sku, quantity, unitPrice));
}
}
EF Core maps _lines via the backing field (builder.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field)).
| Use | When |
|---|---|
Result type (Result.Fail("...")) |
Expected business outcomes callers should handle ("shipped orders can't be cancelled") |
| Domain exception | Programming errors or invariant violations that shouldn't happen if callers behave |
Either way, the decision is made inside the entity.
public sealed class TransferService
{
public Result Transfer(Account from, Account to, Money amount)
{
var withdrawn = from.Withdraw(amount); // tell
if (!withdrawn.IsSuccess) return withdrawn;
to.Deposit(amount); // tell
return Result.Ok();
}
}
The service coordinates; each account still enforces its own rules.
When several types share a behaviour contract (e.g. ICancellable for orders and subscriptions), an interface lets callers "tell" without knowing the concrete type. Keep it small (ISP) and behaviour-shaped (Cancel()), not state-shaped (Status { get; set; }).
Status directly.IReadOnlyList<T> with an AddX method and map the backing field in EF Core.var order = new Order(Guid.NewGuid());
order.MarkPaid();
order.Ship();
var result = order.Cancel();
Console.WriteLine(result.IsSuccess ? "Cancelled" : result.Error); // Shipped orders cannot be cancelled
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }
public readonly record struct Result(bool IsSuccess, string? Error)
{
public static Result Ok() => new(true, null);
public static Result Fail(string error) => new(false, error);
}
public sealed class Order(Guid id)
{
public Guid Id { get; } = id;
public OrderStatus Status { get; private set; } = OrderStatus.Pending; // no external writes
public void MarkPaid()
{
if (Status != OrderStatus.Pending) throw new InvalidOperationException("Only pending orders can be paid");
Status = OrderStatus.Paid;
}
public void Ship()
{
if (Status != OrderStatus.Paid) throw new InvalidOperationException("Only paid orders can ship");
Status = OrderStatus.Shipped;
}
// Tell: the order owns the cancellation rule.
public Result Cancel()
{
if (Status is OrderStatus.Shipped) return Result.Fail("Shipped orders cannot be cancelled");
if (Status is OrderStatus.Cancelled) return Result.Ok();
Status = OrderStatus.Cancelled;
return Result.Ok();
}
}
What happens: order.Status = OrderStatus.Cancelled from anywhere.
Why it is wrong: any code can put the object in an invalid state.
Do instead: private setters and action methods that enforce rules.
What happens: methods added to hide data needed for display. Why it is wrong: screens and reports need data; hiding it adds noise. Do instead: plain query models for reads.
What happens: Order.Cancel() loads and modifies inventory and payments.
Why it is wrong: the entity couples to other aggregates and their loading.
Do instead: a domain/application service that tells each aggregate.
What happens: Ship() returns a list of customer emails.
Why it is wrong: breaks command-query separation; surprises callers.
Do instead: commands return success/failure; queries return data.
What happens: public List<OrderLine> Lines { get; }.
Why it is wrong: callers Add directly and bypass validation.
Do instead: IReadOnlyList<T> plus action methods.
What happens: SetStatus(OrderStatus s) that just assigns.
Why it is wrong: same bypass, different name.
Do instead: domain actions (Cancel, Ship) that encode allowed transitions.
What happens: rich models avoided "because of the ORM". Why it is wrong: EF Core maps private setters, backing fields and constructors. Do instead: use field access mode and parameterised constructors.
What happens: entities call repositories, email services and APIs. Why it is wrong: entities take on infrastructure concerns. Do instead: entities hold rules about their own state; services coordinate I/O.
What happens: Cancel() does nothing for shipped orders and returns void.
Why it is wrong: callers think it worked.
Do instead: return a result or throw so callers must handle it.
What happens: rich-model ceremony for entities with no rules. Why it is wrong: adds code without protecting anything. Do instead: apply it where there are invariants to protect.
I use Tell, Don't Ask to decide where a rule lives. If several services check an entity's state before changing it, the rule belongs on the entity.
| Code pattern | Move to |
|---|---|
if (x.Status == ...) x.Status = ... in services |
Entity method |
| Same check in several handlers | Entity or value object |
| Rule across two aggregates | Domain service |
| Screen needs fields | Query/read model (asking is fine) |
In an anemic domain model, entities are property bags with public getters and setters, and all business rules live in service classes that read and set those properties. In a rich domain model, entities own their behaviour: state is private, changes happen through methods like order.Pay(amount) that enforce invariants, and services only orchestrate (load, call, save). Anemic models scatter and duplicate rules, let any code put objects into invalid states, and are hard to test without a database. They are acceptable for simple CRUD or reporting with few rules. Move towards a richer model incrementally: make setters private, move one rule at a time into the entity, protect collections, and use value objects, guided by where bugs actually occur.
The Law of Demeter (principle of least knowledge) says a method should only call methods on: itself, its parameters, objects it creates, and its own fields. A chain like order.Customer.Address.Postcode.StartsWith("6") couples the caller to the structure of four types, so changing any of them breaks it. The fix is usually to ask the nearest object for what you need (order.ShipsToWa()), which moves behaviour to where the data lives. It is about navigating object structures, not counting dots: LINQ and fluent builders return the same abstraction each call, and plain DTOs are data, so neither is a violation.
SRP says a module should have one reason to change, which Robert C. Martin later phrased as "responsible to one actor": one group of people whose requests drive its changes. It is not "a class does one thing" or "one method per class". In .NET I look for classes that change for unrelated reasons, such as an OrderService that holds pricing rules (finance), email templates (marketing) and SQL (DBA), and split along those change drivers. I stop splitting when the pieces would always change together, because that is just scattering one responsibility across files.
How to tell it (STAR)
Five different services could cancel orders and one forgot that shipped orders cannot be cancelled, so we refunded [hundreds] of shipped orders. I moved the rule into a `Cancel` method on the order and made the status setter private. Now there is only one way to cancel, and it cannot skip the check.
Last updated 31 Oct 2026.