Loading content...
Understanding Tell, Don't Ask conceptually is one thing. Applying it instinctively in the heat of development is another. This page bridges that gap with extensive real-world examples that demonstrate how TDA transforms code from interrogative proceduralism to true object-oriented collaboration.
We'll examine scenarios across multiple domains, each showing the 'ask-heavy' approach alongside the 'tell-oriented' transformation. By seeing the same pattern repeat across different contexts, you'll develop the pattern recognition that makes TDA second nature.
By the end of this page, you will have seen TDA applied to e-commerce systems, banking operations, game development, notification systems, and more. Each example demonstrates the transformation from procedural to object-oriented thinking, highlighting the specific improvements in encapsulation, maintainability, and expressiveness.
Shopping carts are ubiquitous in e-commerce systems. Let's examine how TDA transforms cart operations from procedural to object-oriented.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Ask-Heavy Anti-Pattern: Procedural cart processingclass CartCheckoutService { checkout(cart: Cart): Order { // Asking cart for its data const items = cart.getItems(); const customerId = cart.getCustomerId(); const promoCode = cart.getPromoCode(); // Performing calculations externally let subtotal = 0; for (const item of items) { const price = item.getPrice(); const quantity = item.getQuantity(); subtotal += price * quantity; } // More external logic based on cart state let discount = 0; if (promoCode) { const promo = this.promoService.getPromo(promoCode); if (promo.getType() === 'percentage') { discount = subtotal * promo.getValue() / 100; } else if (promo.getType() === 'fixed') { discount = promo.getValue(); } } // Checking customer status externally const customer = this.customerService.getCustomer(customerId); if (customer.getMembershipLevel() === 'premium') { discount += subtotal * 0.05; // Extra 5% for premium } const total = subtotal - discount; // Validating externally if (items.length === 0) { throw new Error('Cart is empty'); } if (total < 0) { throw new Error('Invalid total'); } // Creating order with extracted data const order = new Order(); order.setCustomerId(customerId); order.setItems(items); order.setSubtotal(subtotal); order.setDiscount(discount); order.setTotal(total); // Clearing cart externally cart.setItems([]); cart.setPromoCode(null); return order; }}Problems with this approach:
Banking operations are classic examples where TDA dramatically improves code quality and safety. Let's examine money transfers.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Ask-Heavy Anti-Pattern: External manipulationclass TransferService { transfer( sourceId: string, targetId: string, amount: number ): TransferResult { const source = this.accountRepo.find(sourceId); const target = this.accountRepo.find(targetId); // Asking accounts for their state const sourceBalance = source.getBalance(); const sourceStatus = source.getStatus(); const sourceLimit = source.getDailyLimit(); const todaysTransfers = source.getTodaysTransferTotal(); const targetStatus = target.getStatus(); const targetType = target.getType(); // External validation using extracted state if (sourceStatus !== 'active') { throw new Error('Source account inactive'); } if (targetStatus !== 'active') { throw new Error('Target account inactive'); } if (sourceBalance < amount) { throw new Error('Insufficient funds'); } if (todaysTransfers + amount > sourceLimit) { throw new Error('Daily limit exceeded'); } if (targetType === 'closed' || targetType === 'frozen') { throw new Error('Cannot transfer to this account'); } // External state manipulation source.setBalance(sourceBalance - amount); source.setTodaysTransferTotal(todaysTransfers + amount); target.setBalance(target.getBalance() + amount); // External record keeping source.addTransaction(new Transaction('debit', amount, targetId)); target.addTransaction(new Transaction('credit', amount, sourceId)); return new TransferResult(true, source.getBalance()); }}Problems:
In real banking systems, accounts might also raise domain events like TransferCompleted that other parts of the system react to (notifications, audit logs). These events are raised internally by the account, not created externally—another form of 'tell' over 'ask'.
Game development offers rich examples where TDA keeps combat systems maintainable. Let's examine character-vs-character combat.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Ask-Heavy Anti-Pattern: External combat logicclass CombatEngine { attack(attacker: Character, defender: Character): CombatResult { // Asking characters for all their stats const attackPower = attacker.getStrength() + attacker.getWeaponDamage(); const attackerLevel = attacker.getLevel(); const attackerBuffs = attacker.getActiveBuffs(); const defenderArmor = defender.getArmor(); const defenderLevel = defender.getLevel(); const defenderHealth = defender.getHealth(); const defenderShield = defender.getShield(); const defenderDebuffs = defender.getActiveDebuffs(); // Complex external calculations let damage = attackPower * (1 + (attackerLevel - defenderLevel) * 0.1); // External buff processing for (const buff of attackerBuffs) { if (buff.getType() === 'damage_boost') { damage *= 1 + buff.getValue(); } } // External debuff processing for (const debuff of defenderDebuffs) { if (debuff.getType() === 'armor_reduction') { defenderArmor *= 1 - debuff.getValue(); } } // External damage calculation const mitigatedDamage = Math.max(0, damage - defenderArmor); // External shield logic let remainingDamage = mitigatedDamage; if (defenderShield > 0) { if (defenderShield >= remainingDamage) { defender.setShield(defenderShield - remainingDamage); remainingDamage = 0; } else { remainingDamage -= defenderShield; defender.setShield(0); } } // External health modification const newHealth = Math.max(0, defenderHealth - remainingDamage); defender.setHealth(newHealth); // External status updates if (newHealth === 0) { defender.setStatus('dead'); attacker.addExperience(defenderLevel * 10); } return new CombatResult(mitigatedDamage, newHealth === 0); }}Problems:
Notification systems often involve complex rules about when, how, and to whom to send notifications. Let's see how TDA handles this elegantly.
123456789101112131415161718192021222324252627282930313233343536
// Ask-Heavy: External orchestrationclass NotificationSender { notify(userId: string, event: Event) { const user = this.userRepo.find(userId); const prefs = user.getPreferences(); // Ask about every preference if (prefs.getEmailEnabled()) { const email = user.getEmail(); if (email && prefs.getEmailFor(event.type)) { this.sendEmail(email, event.message); } } if (prefs.getSmsEnabled()) { const phone = user.getPhone(); if (phone && prefs.getSmsFor(event.type)) { this.sendSms(phone, event.message); } } if (prefs.getPushEnabled()) { const tokens = user.getPushTokens(); if (tokens.length > 0 && prefs.getPushFor(event.type)) { for (const token of tokens) { this.sendPush(token, event.message); } } } // Log based on more extracted state if (prefs.getLogEnabled()) { this.logger.log(userId, event); } }}1234567891011121314151617181920212223242526272829303132333435363738394041
// Tell-Oriented: User handles own notificationsclass NotificationSender { notify(userId: string, event: Event) { const user = this.userRepo.find(userId); // Just tell the user to handle it user.notify(event); }} class User { private preferences: NotificationPreferences; private channels: NotificationChannel[]; notify(event: Event): void { // User knows its channels and preferences const applicableChannels = this.channels .filter(ch => this.preferences.allows(ch, event.type)); // Tell each channel to deliver applicableChannels.forEach(ch => ch.deliver(event)); }} interface NotificationChannel { deliver(event: Event): void;} class EmailChannel implements NotificationChannel { constructor(private email: string) {} deliver(event: Event): void { /* ... */ }} class SmsChannel implements NotificationChannel { constructor(private phone: string) {} deliver(event: Event): void { /* ... */ }} class PushChannel implements NotificationChannel { constructor(private tokens: string[]) {} deliver(event: Event): void { /* ... */ }}The transformation highlights:
deliver() its own way; adding Slack, Discord, etc. doesn't change the senderNotificationPreferences, not scattered in the senderNotificationSenderProcessing pipelines often involve chains of transformations. Let's see how TDA creates clean, extensible pipelines.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// Ask-Heavy: External processing with type checkingclass DocumentProcessor { process(document: Document): ProcessedDocument { const type = document.getType(); const content = document.getContent(); const metadata = document.getMetadata(); let processed: string; // Type-based branching with extracted data if (type === 'pdf') { processed = this.pdfParser.parse(content); const pageCount = this.pdfParser.getPageCount(content); metadata.set('pageCount', pageCount); } else if (type === 'word') { processed = this.wordParser.parse(content); const wordCount = this.wordParser.getWordCount(content); metadata.set('wordCount', wordCount); } else if (type === 'excel') { processed = this.excelParser.parse(content); const sheetCount = this.excelParser.getSheetCount(content); metadata.set('sheetCount', sheetCount); } else { processed = content.toString(); } // More external processing const compressed = this.compressor.compress(processed); document.setProcessedContent(compressed); document.setMetadata(metadata); return new ProcessedDocument(document); }} // ================================================================ // Tell-Oriented: Documents process themselvesclass DocumentProcessor { process(document: Document): ProcessedDocument { // Just tell the document to process return document.process(); }} abstract class Document { protected content: Buffer; protected metadata: Metadata; abstract process(): ProcessedDocument; protected compress(text: string): Buffer { return Compressor.compress(text); }} class PdfDocument extends Document { process(): ProcessedDocument { const text = this.extractText(); this.metadata.set('pageCount', this.countPages()); const compressed = this.compress(text); return new ProcessedDocument(compressed, this.metadata); } private extractText(): string { /* PDF-specific extraction */ } private countPages(): number { /* PDF-specific counting */ }} class WordDocument extends Document { process(): ProcessedDocument { const text = this.extractText(); this.metadata.set('wordCount', this.countWords()); const compressed = this.compress(text); return new ProcessedDocument(compressed, this.metadata); } private extractText(): string { /* Word-specific extraction */ } private countWords(): number { /* Word-specific counting */ }} class ExcelDocument extends Document { process(): ProcessedDocument { const text = this.extractAllCells(); this.metadata.set('sheetCount', this.countSheets()); const compressed = this.compress(text); return new ProcessedDocument(compressed, this.metadata); } private extractAllCells(): string { /* Excel-specific extraction */ } private countSheets(): number { /* Excel-specific counting */ }}Notice how adding a new document type (say, Markdown) in the tell-oriented version requires only creating a new class. The processor never changes. In the ask-oriented version, every new type requires modifying the processor—a direct violation of the Open/Closed Principle.
State machines are everywhere in software—order processing, workflow engines, game states. TDA makes state transitions safe and encapsulated.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// Ask-Heavy: External state managementclass OrderWorkflow { ship(orderId: string): void { const order = this.orderRepo.find(orderId); const status = order.getStatus(); // External validation of state transitions if (status !== 'paid') { throw new Error( `Cannot ship order in status ${status}` ); } // External state change order.setStatus('shipped'); order.setShippedAt(new Date()); // External side effects const customer = order.getCustomer(); this.emailService.sendShippingNotification( customer.getEmail(), order.getTrackingNumber() ); } cancel(orderId: string): void { const order = this.orderRepo.find(orderId); const status = order.getStatus(); // Duplicated transition logic if (status === 'shipped' || status === 'delivered') { throw new Error('Cannot cancel shipped order'); } order.setStatus('cancelled'); order.setCancelledAt(new Date()); // Refund logic based on previous status if (status === 'paid') { this.refundService.refund(order.getPaymentId()); } }} // ================================================================ // Tell-Oriented: Order manages its own state machineclass OrderWorkflow { ship(orderId: string): void { const order = this.orderRepo.find(orderId); order.ship(); // Tell order to transition this.orderRepo.save(order); } cancel(orderId: string): void { const order = this.orderRepo.find(orderId); order.cancel(); // Tell order to transition this.orderRepo.save(order); }} class Order { private status: OrderStatus; private customer: Customer; private payment?: Payment; private timestamps: OrderTimestamps; private events: DomainEvent[] = []; ship(): void { // Order validates its own transitions this.status.ensureCanTransitionTo(OrderStatus.Shipped); // Order performs transition this.status = OrderStatus.Shipped; this.timestamps.shipped = new Date(); // Order raises events for side effects this.events.push(new OrderShippedEvent(this.id, this.customer.id)); } cancel(): void { this.status.ensureCanTransitionTo(OrderStatus.Cancelled); const previousStatus = this.status; this.status = OrderStatus.Cancelled; this.timestamps.cancelled = new Date(); // Order knows when refund is needed if (previousStatus === OrderStatus.Paid) { this.events.push(new RefundRequestedEvent(this.payment!.id)); } this.events.push(new OrderCancelledEvent(this.id)); } getEvents(): DomainEvent[] { return [...this.events]; }} class OrderStatus { private static transitions: Map<OrderStatus, OrderStatus[]> = /* ... */; ensureCanTransitionTo(target: OrderStatus): void { const allowed = OrderStatus.transitions.get(this) || []; if (!allowed.includes(target)) { throw new InvalidTransitionError(this, target); } }}State machine benefits with TDA:
OrderStatus class knows what transitions are validValidation is a prime area where TDA shines. Instead of external validators interrogating objects, objects validate themselves.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// Value Object with self-validation: Emailclass Email { private readonly value: string; private constructor(value: string) { this.value = value; } static create(value: string): Email { // Validation happens at creation if (!value || value.trim().length === 0) { throw new InvalidEmailError('Email cannot be empty'); } if (!Email.isValidFormat(value)) { throw new InvalidEmailError(`Invalid email format: ${value}`); } if (!Email.isAllowedDomain(value)) { throw new InvalidEmailError(`Domain not allowed: ${value}`); } return new Email(value.toLowerCase().trim()); } private static isValidFormat(value: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } private static isAllowedDomain(value: string): boolean { const blockedDomains = ['tempmail.com', 'throwaway.net']; const domain = value.split('@')[1]; return !blockedDomains.includes(domain); } getDomain(): string { return this.value.split('@')[1]; } toString(): string { return this.value; }} // Entity with self-validation: Userclass User { private email: Email; // Always valid - Email validates itself private name: UserName; // Always valid - UserName validates itself private age: Age; // Always valid - Age validates itself private constructor(email: Email, name: UserName, age: Age) { this.email = email; this.name = name; this.age = age; } static create(emailStr: string, nameStr: string, ageNum: number): User { // Each value object validates on creation const email = Email.create(emailStr); const name = UserName.create(nameStr); const age = Age.create(ageNum); return new User(email, name, age); } updateEmail(newEmailStr: string): void { // Validation happens automatically in Email.create this.email = Email.create(newEmailStr); }} // Usage: impossible to create invalid objectstry { const user = User.create('invalid-email', 'Jo', -5);} catch (error) { // Will fail at Email.create with clear error} const validUser = User.create('john@example.com', 'John Doe', 30);// validUser is guaranteed to be valid - no external validation neededWhen objects validate themselves at construction and on every mutation, they are 'always valid.' You never need to check 'is this object valid?' because invalid objects simply don't exist. This eliminates entire categories of bugs and makes reasoning about code dramatically simpler.
We've seen Tell, Don't Ask applied across diverse domains. Let's summarize the patterns:
The Common Thread:
In every example, the transformation follows the same pattern:
The result is always:
What's Next:
While Tell, Don't Ask is a powerful principle, it's not absolute. The final page explores when asking is appropriate—legitimate cases where queries are necessary and beneficial, and how to balance pure TDA with practical realities.
You've now seen Tell, Don't Ask applied across e-commerce, banking, gaming, notifications, file processing, state machines, and validation. These patterns will recur throughout your career—you're now equipped to recognize opportunities and apply TDA transformations instinctively.