Loading content...
Throughout this module, we've championed 'telling' over 'asking.' We've seen how asking breaks encapsulation, scatters logic, and creates procedural code in object clothing. But here's a crucial truth:
Tell, Don't Ask is a guideline, not a law.
Blindly eliminating all queries can be as harmful as overusing them. There are legitimate, important cases where asking is not only acceptable but the right choice. Understanding when to ask—and why—separates dogmatic principle application from mature engineering judgment.
By the end of this page, you will understand the legitimate use cases for queries, how to distinguish harmful 'asking' from necessary 'asking', the relationship between TDA and Command-Query Separation, and how to balance principle adherence with practical system needs. You'll develop the judgment to apply TDA appropriately.
The most obvious legitimate use of queries is for display and reporting. User interfaces need data to render; reports need data to process. You can't 'tell' a dashboard to display itself without asking for the data to display.
Consider a user profile page. The UI layer needs to render:
This requires asking the User object for these values. There's no 'telling' alternative—the UI isn't the domain; it's a representation of domain state. Asking is appropriate here.
123456789101112131415161718192021222324252627282930
// Legitimate: Queries for UI displayclass UserProfileComponent { render(user: User): JSX.Element { // These queries are for display - completely appropriate return ( <div className="profile"> <Avatar src={user.getAvatarUrl()} /> <h1>{user.getDisplayName()}</h1> <Badge level={user.getMembershipLevel()} /> <span>Last login: {user.getLastLoginDate()}</span> </div> ); }} // Legitimate: Queries for reportingclass UserActivityReport { generate(users: User[]): Report { return new Report({ totalUsers: users.length, activeUsers: users.filter(u => u.isActive()).length, avgSessionDuration: this.calculateAverage( users.map(u => u.getAverageSessionDuration()) ), membershipBreakdown: this.groupBy( users.map(u => u.getMembershipLevel()) ), }); }}In CQRS (Command-Query Responsibility Segregation) architectures, queries and commands are explicitly separated. The 'read side' is optimized for querying—it's meant to be asked. TDA applies primarily to the 'write side' where commands (tells) modify state. This architectural pattern acknowledges that asking is essential for reads.
Bertrand Meyer's Command-Query Separation (CQS) principle distinguishes:
The key insight: pure queries are safe. If a method doesn't change state, calling it has no consequences. You can call it zero times, once, or a hundred times—the object remains unchanged.
The danger of 'asking' in the TDA sense is when you:
But simply asking for information with no follow-up action? That's fine.
| Query Type | Example | Issue? |
|---|---|---|
| Display query | user.getName() to show in UI | ✅ Safe — no decision or action follows |
| Comparison query | a.getValue() === b.getValue() for equality | ✅ Safe — comparing values, not acting on them |
| Logging query | order.getTotal() for audit log | ✅ Safe — recording, not deciding |
| Decision query | if (user.getStatus() === 'active') { ... do something ... } | ⚠️ Depends — is the 'do something' the user's job? |
| Extraction-action query | x = obj.getX(); x += 1; obj.setX(x); | ❌ Problematic — behavior belongs in object |
The Decision Heuristic:
When you find yourself asking for data, ask: 'What am I going to do with this?'
The problem isn't querying—it's querying to do the object's job for it.
In complex systems, some layer must coordinate between objects that don't know about each other. This coordination often requires asking one object for information to pass to another.
Consider an e-commerce checkout flow that involves:
The Order can't directly call the Payment Gateway—that would create a domain-to-infrastructure dependency (violating DIP). An application service must orchestrate:
123456789101112131415161718192021222324252627282930313233343536373839
// Orchestration requires some querying - this is acceptableclass CheckoutOrchestrator { async checkout(orderId: string): Promise<CheckoutResult> { const order = await this.orderRepo.findById(orderId); // Tell order to prepare for checkout (validates itself, calculates total) order.prepareForCheckout(); // QUERY: Need to pass order's total to external payment gateway // Order can't call payment gateway directly (dependency direction) const paymentResult = await this.paymentGateway.charge( order.getTotal(), // Query is necessary for coordination order.getPaymentMethodId() // Query is necessary for coordination ); // Tell order to record payment (order handles its state change) order.recordPayment(paymentResult.transactionId); // QUERY: Need order's weight/dimensions for shipping const shippingLabel = await this.shippingProvider.createLabel( order.getShippingAddress(), // Query for coordination order.getWeight(), // Query for coordination order.getDimensions() // Query for coordination ); // Tell order to record shipping order.recordShipment(shippingLabel.trackingNumber); await this.orderRepo.save(order); // QUERY: Need info for notification await this.notificationService.sendOrderConfirmation( order.getCustomerEmail(), // Query for coordination order.getOrderNumber() // Query for coordination ); return new CheckoutResult(order.getOrderNumber()); }}Why These Queries Are Acceptable:
They're for coordination, not logic. The business logic (validation, total calculation, state transitions) happens inside Order. The queries just pass necessary data to external systems.
The alternative is worse. Should Order call PaymentGateway directly? That would violate architectural boundaries and couple domain to infrastructure.
The orchestrator isn't deciding. It's not using getStatus() to decide what to do—it's using getTotal() to pass to an external system.
The orchestrator follows a pattern: tell the domain object to do its job, then query what's needed for external coordination.
An alternative to coordination queries is domain events. Order could raise an OrderReadyForPayment event containing the necessary data. Event handlers then process payment without querying Order. This is more complex but eliminates coordination queries.
When dealing with collections of objects, you often need to aggregate, filter, or transform them. This inherently involves querying each object for relevant properties.
Consider calculating the total value of all inventory items, finding all orders above a threshold, or grouping customers by region. These operations query objects to produce aggregated results.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Collection operations require queries - this is normalclass InventoryAnalytics { calculateTotalValue(items: InventoryItem[]): Money { // Querying each item for its value - necessary for aggregation return items.reduce( (sum, item) => sum.plus(item.getValue()), Money.zero() ); } findLowStockItems(items: InventoryItem[], threshold: number): InventoryItem[] { // Querying stock level for filtering - appropriate return items.filter(item => item.getStockLevel() < threshold); } groupByCategory(items: InventoryItem[]): Map<string, InventoryItem[]> { // Querying category for grouping - appropriate const grouped = new Map<string, InventoryItem[]>(); for (const item of items) { const category = item.getCategory(); if (!grouped.has(category)) { grouped.set(category, []); } grouped.get(category)!.push(item); } return grouped; }} // Often better: Let the collection object handle aggregationclass Inventory { private items: InventoryItem[]; getTotalValue(): Money { // Tell inventory to calculate - it asks its items internally return this.items.reduce( (sum, item) => sum.plus(item.getValue()), Money.zero() ); } getLowStockItems(threshold: number): InventoryItem[] { return this.items.filter(item => item.getStockLevel() < threshold); } getByCategory(category: string): InventoryItem[] { return this.items.filter(item => item.getCategory() === category); }}The Collection Object Pattern:
Notice the second example: by having an Inventory object that encapsulates the collection, we can turn external aggregation queries into 'tell' calls. Instead of querying each InventoryItem externally, we tell Inventory to give us the total value or low-stock items.
This pushes the aggregation logic into the collection object—a form of TDA at the collection level. The individual item queries still happen, but they're internal to the Inventory, not scattered across the codebase.
Takeaway: When possible, encapsulate collections in objects that provide aggregation methods. When that's not practical, querying items for collection operations is acceptable.
When integrating with external systems—APIs, databases, message queues, file systems—you often need to extract data from domain objects to send externally. This is fundamentally a query operation.
External systems don't understand your objects. They understand JSON, XML, SQL, or other data formats. You must serialize your objects, which means asking them for their data.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// External integration requires serialization - queries are necessaryclass OrderApiClient { async submitOrder(order: Order): Promise<ApiResponse> { // Must query order to create the API payload const payload = { orderId: order.getId(), customerId: order.getCustomerId(), items: order.getItems().map(item => ({ productId: item.getProductId(), quantity: item.getQuantity(), price: item.getPrice().toDecimal(), })), total: order.getTotal().toDecimal(), shippingAddress: order.getShippingAddress().toApiFormat(), }; return await this.httpClient.post('/orders', payload); }} // Better: Tell the object to serialize itselfclass Order { // Order knows how to represent itself for external systems toApiPayload(): OrderApiPayload { return { orderId: this.id, customerId: this.customerId, items: this.items.map(item => item.toApiPayload()), total: this.total.toDecimal(), shippingAddress: this.shippingAddress.toApiFormat(), }; } toJson(): string { return JSON.stringify(this.toApiPayload()); }} class OrderApiClient { async submitOrder(order: Order): Promise<ApiResponse> { // Tell order to provide its API representation const payload = order.toApiPayload(); return await this.httpClient.post('/orders', payload); }}Notice how order.toApiPayload() transforms queries into a tell. Instead of asking for each field separately, we tell the order to serialize itself. The object controls its external representation. If the serialization format changes, only the toApiPayload() method changes—callers are unaffected.
Some operations inherently require accessing object state:
These operations are fundamentally about asking objects about their values. They don't modify state or cause side effects—they're pure queries that are essential for any non-trivial system.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Value Object: Equality based on valueclass Money { private readonly amount: number; private readonly currency: Currency; // Queries for equality - absolutely necessary equals(other: Money): boolean { return this.amount === other.amount && this.currency.equals(other.currency); } // Queries for comparison - necessary for sorting, ordering greaterThan(other: Money): boolean { this.ensureSameCurrency(other); return this.amount > other.amount; } lessThan(other: Money): boolean { this.ensureSameCurrency(other); return this.amount < other.amount; } // These are legitimate queries getAmount(): number { return this.amount; } getCurrency(): Currency { return this.currency; }} // Entity: Identity comparisonclass User { private readonly id: UserId; private name: string; private email: Email; // Entity equality is based on identity, not value equals(other: User): boolean { return this.id.equals(other.id); } // Identity query - legitimate getId(): UserId { return this.id; }} // Comparison for collectionsclass Event { private readonly startTime: Date; private readonly endTime: Date; // Comparison for sorting - legitimate query usage isBefore(other: Event): boolean { return this.startTime < other.startTime; } overlaps(other: Event): boolean { return this.startTime < other.endTime && this.endTime > other.startTime; }}The Nature of Equality and Comparison:
These operations are queries by definition—they compute a boolean or ordering based on internal state without modifying it. Trying to 'tell' objects to check equality doesn't make sense semantically.
What matters is that the comparison logic lives in the object. money1.greaterThan(money2) is preferable to external comparison like money1.getAmount() > money2.getAmount() because:
Tell, Don't Ask emerged from object-oriented programming where objects have mutable state. In functional programming and immutable design, the paradigm shifts:
In this paradigm, the 'tell' vs 'ask' distinction becomes less relevant. You're always 'asking' for values because there's no mutable state to 'tell' an object to change.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Immutable Value Objects - "asking" produces new valuesclass Point { constructor( readonly x: number, readonly y: number ) {} // Returns a new Point - no mutation moveBy(dx: number, dy: number): Point { return new Point(this.x + dx, this.y + dy); } // Returns a new Point - composition of immutable values midpoint(other: Point): Point { return new Point( (this.x + other.x) / 2, (this.y + other.y) / 2 ); } distanceTo(other: Point): number { return Math.sqrt( (this.x - other.x) ** 2 + (this.y - other.y) ** 2 ); }} // Immutable Moneyclass Money { constructor( readonly amount: number, readonly currency: Currency ) {} plus(other: Money): Money { this.ensureSameCurrency(other); return new Money(this.amount + other.amount, this.currency); } times(multiplier: number): Money { return new Money(this.amount * multiplier, this.currency); }} // Usage: transformations without mutationconst start = new Point(0, 0);const moved = start.moveBy(10, 20); // start unchangedconst movedAgain = moved.moveBy(5, -10); // moved unchanged const price = new Money(100, Currency.USD);const withTax = price.times(1.08); // price unchangedconst total = withTax.plus(new Money(10, Currency.USD)); // withTax unchangedTDA in Immutable Contexts:
In immutable designs, the 'tell' translates to 'transform': instead of telling an object to change its state, you ask it to produce a new version of itself with the desired change.
point.moveBy(10, 20) — Semantically a 'tell,' returns new Pointmoney.plus(other) — Semantically a 'tell,' returns new MoneyThe principle still applies conceptually: the logic for moving points lives in Point, not externally. But the mechanism is different—transformation instead of mutation.
Takeaway: TDA's spirit (encapsulation, cohesion) applies to immutable designs, but the mechanics differ. Queries that return new values are the immutable equivalent of commands.
Many modern systems blend paradigms: immutable value objects within mutable entities, functional transformations within OO orchestration. When designing, choose the approach that fits the domain. Value objects are often better immutable; entities often need mutation. TDA guides either style.
The hallmark of mature engineering is knowing when to break rules. Principles like TDA are guides, not commandments. Blindly following any principle leads to code that serves the principle rather than the system's actual needs.
Here's how to apply TDA with appropriate judgment:
toApiPayload() or getSummary() that bundle multiple values, rather than many fine-grained getters.The Spectrum of Application:
Think of TDA as a spectrum:
| Context | TDA Strictness |
|---|---|
| Domain objects / Business logic | Very high — behavior must be encapsulated |
| Application services / Orchestration | High — tell domain objects, query for coordination |
| Infrastructure adapters | Medium — serialization, persistence need data |
| Presentation / UI | Lower — queries for display are natural |
| Scripts, prototypes, utilities | Flexible — choose based on lifetime and complexity |
The higher the level of business logic and the longer the code will live, the more TDA matters.
Absolute adherence to any principle—TDA, DRY, SOLID, or any other—leads to worse code than thoughtful violation. Principles encode wisdom; wisdom includes knowing when the principle doesn't apply. The goal is well-designed, maintainable software, not principle-compliance scores.
Just as too much 'asking' creates procedural code, pushing TDA too far creates its own problems. Here are signs you've over-applied the principle:
toHtml() or render() methods to domain objects to avoid queries. Domain shouldn't know about presentation.if would be clearer.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Over-application: UI logic in domain objectclass Product { // WRONG: Domain object shouldn't know about HTML toHtml(): string { return `<div class="product"> <h2>${this.name}</h2> <p class="price">${this.price.format()}</p> </div>`; } // BETTER: Just provide data, let UI layer render getName(): string { return this.name; } getPrice(): Money { return this.price; }} // Over-application: Convoluted visitor to avoid queryinterface UserVisitor<T> { visitAdmin(user: AdminDetails): T; visitCustomer(user: CustomerDetails): T; visitGuest(user: GuestDetails): T;} class User { accept<T>(visitor: UserVisitor<T>): T { /* ... */ }} // Just to check if user is admin:const isAdmin = user.accept({ visitAdmin: () => true, visitCustomer: () => false, visitGuest: () => false,}); // SIMPLER: Just askconst isAdmin = user.isAdmin(); // Over-application: Passing unnecessary context to avoid asking// WRONG: Passing logger to avoid order.getOrderNumber()class Order { confirmWithLogging(logger: Logger): void { this.confirm(); logger.log(`Order ${this.orderNumber} confirmed`); }} // BETTER: Separate concerns, allow asking for displayclass OrderService { confirm(orderId: string): void { const order = this.repo.find(orderId); order.confirm(); this.logger.log(`Order ${order.getOrderNumber()} confirmed`); }}Let's consolidate when asking is not just acceptable, but appropriate:
Module Conclusion:
Tell, Don't Ask is a powerful heuristic for object-oriented design. It guides you toward encapsulation, cohesion, and truly object-oriented code. But like all principles, it requires judgment in application.
Remember:
With Tell, Don't Ask internalized, you'll write code where objects are intelligent collaborators rather than passive data containers. Your systems will be more maintainable, extensible, and aligned with object-oriented philosophy.
Congratulations! You've completed the Tell, Don't Ask module. You understand when to tell (commands, behavior), when to ask (display, coordination, comparison), and how to balance the principle with pragmatic needs. You can now apply TDA judiciously to build truly object-oriented systems where behavior lives with data and objects collaborate effectively.