Picture this: You're at a restaurant, and instead of simply ordering "I'll have the biryani," you walk into the kitchen, check if they have rice and chicken, verify the chef is available, inspect the stove temperature, and then tell them to start cooking. Absurd, right? Yet this is exactly what many developers do with their objects every single day.
This article explores the Tell, Don't Ask principle—a fundamental design guideline that transforms how you write object-oriented C# code. You'll learn how to stop interrogating your objects and start commanding them, resulting in code that's more maintainable, testable, and truly object-oriented.
What You'll Learn
The core philosophy behind Tell, Don't Ask
How to identify violations in your codebase
Practical C# examples with before/after refactorings
How Tell, Don't Ask works with SOLID, DRY, and KISS principles
When it's acceptable to "ask" instead of "tell"
Common pitfalls and how to avoid them
Prerequisites
Basic understanding of C# and .NET (Framework or Core)
Familiarity with object-oriented programming concepts
Experience with classes, interfaces, and encapsulation
Knowledge of SOLID principles is helpful but not required
Understanding the Tell, Don't Ask Principle
The Core Philosophy
Tell, Don't Ask is a design principle that encourages you to tell objects what you want them to do, rather than asking them for their state and making decisions on their behalf. It's about respecting encapsulation and trusting objects to manage their own behaviour.
The principle was popularised by Martin Fowler and the Pragmatic Programmers. At its heart, it reminds us that object-oriented programming is about bundling data with the functions that operate on that data.
The Problem with "Asking"
When you "ask" an object for its state and then make decisions based on that state, you're essentially:
Breaking encapsulation - You're exposing internal details that should remain hidden
Scattering business logic - Decision-making code ends up in the wrong places
Creating tight coupling - Client code becomes dependent on the object's internal structure
Violating Single Responsibility - Objects can't control their own behaviour
A Real-World Analogy
Think of your car. When you want to stop, you don't:
Check the brake fluid level
Verify the brake pads thickness
Calculate the stopping distance
Then manually engage the brake system
Instead, you simply press the brake pedal. You tell the car to stop, and it handles all the internal complexity. The car encapsulates the "how" behind the "what."
This is exactly how your objects should work.
Example 1: Shopping Cart Discount Calculator
Let's start with a common scenario: calculating discounts in a shopping cart.
The "Ask" Approach (Bad)
Here's what violating Tell, Don't Ask looks like:
public class ShoppingCart
{
public List<CartItem> Items { get; set; } = new List<CartItem>();
public decimal TotalAmount { get; set; }
public string CustomerType { get; set; } // "Regular", "Premium", "VIP"
}
public class CartItem
{
public string ProductName { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
}
public class CheckoutService
{
public decimal ProcessCheckout(ShoppingCart cart)
{
// VIOLATION: Asking cart for items and calculating externally
decimal totalAmount = 0;
foreach (var item in cart.Items)
{
totalAmount += item.Price * item.Quantity;
}
// VIOLATION: Checking customer type and calculating discount
decimal discount = 0;
if (cart.CustomerType == "Premium")
{
discount = totalAmount * 0.10m; // 10% discount
}
else if (cart.CustomerType == "VIP")
{
discount = totalAmount * 0.20m; // 20% discount
}
cart.TotalAmount = totalAmount - discount;
return cart.TotalAmount;
}
}
Problems with This Approach
CheckoutService knows too much: It understands the internal structure of the cart and how discounts work
Business logic is scattered: Discount calculation lives in the wrong place
Hard to extend: Adding a new customer type means modifying the CheckoutService
Testing is difficult: You can't test discount logic without the checkout service
Violates encapsulation: The cart can't protect its own invariants
The "Tell" Approach (Good)
Now let's refactor to follow Tell, Don't Ask:
public class ShoppingCart
{
private List<CartItem> _items = new List<CartItem>();
private ICustomerDiscountStrategy _discountStrategy;
public ShoppingCart(ICustomerDiscountStrategy discountStrategy)
{
_discountStrategy = discountStrategy ??
throw new ArgumentNullException(nameof(discountStrategy));
}
public void AddItem(CartItem item)
{
if (item == null)
throw new ArgumentNullException(nameof(item));
_items.Add(item);
}
public decimal CalculateTotal()
{
decimal subtotal = CalculateSubtotal();
decimal discount = _discountStrategy.CalculateDiscount(subtotal);
return subtotal - discount;
}
private decimal CalculateSubtotal()
{
return _items.Sum(item => item.GetTotalPrice());
}
}
public class CartItem
{
private readonly string _productName;
private readonly decimal _price;
private readonly int _quantity;
public CartItem(string productName, decimal price, int quantity)
{
if (string.IsNullOrWhiteSpace(productName))
throw new ArgumentException("Product name is required");
if (price <= 0)
throw new ArgumentException("Price must be positive");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
_productName = productName;
_price = price;
_quantity = quantity;
}
public decimal GetTotalPrice() => _price * _quantity;
}
// Strategy Pattern for discount calculation
public interface ICustomerDiscountStrategy
{
decimal CalculateDiscount(decimal subtotal);
}
public class RegularCustomerDiscount : ICustomerDiscountStrategy
{
public decimal CalculateDiscount(decimal subtotal) => 0;
}
public class PremiumCustomerDiscount : ICustomerDiscountStrategy
{
public decimal CalculateDiscount(decimal subtotal) => subtotal * 0.10m;
}
public class VIPCustomerDiscount : ICustomerDiscountStrategy
{
public decimal CalculateDiscount(decimal subtotal) => subtotal * 0.20m;
}
public class CheckoutService
{
public decimal ProcessCheckout(ShoppingCart cart)
{
// TELL the cart to calculate its total
return cart.CalculateTotal();
}
}
Benefits of This Refactoring
✅ Encapsulation restored: The cart manages its own calculation logic
✅ Simple checkout service: It just tells the cart what to do
✅ Easy to extend: New customer types don't require modifying existing code (Open/Closed Principle)
✅ Testable: You can test discount strategies independently
✅ Clear responsibilities: Each class has one job
Key Takeaway: Instead of asking the cart for its items and customer type, we simply tell it to calculate the total. The cart knows how to do this because it owns the data and the behaviour.
Example 2: Bank Account Withdrawal
Let's look at another common scenario that often violates Tell, Don't Ask.
The "Ask" Approach (Bad)
public class BankAccount
{
public string AccountNumber { get; set; }
public decimal Balance { get; set; }
public decimal MinimumBalance { get; set; } = 100m;
public bool IsActive { get; set; }
}
public class ATMService
{
public void WithdrawMoney(BankAccount account, decimal amount)
{
// VIOLATION: Asking account for multiple properties
if (!account.IsActive)
{
throw new InvalidOperationException("Account is not active");
}
if (account.Balance < amount)
{
throw new InvalidOperationException("Insufficient funds");
}
if (account.Balance - amount < account.MinimumBalance)
{
throw new InvalidOperationException(
"Cannot withdraw: minimum balance requirement");
}
// Finally update balance
account.Balance -= amount;
Console.WriteLine($"Withdrew {amount:C}. New balance: {account.Balance:C}");
}
}
The ATMService is doing way too much: checking if the account is active, validating sufficient funds, enforcing minimum balance rules, and updating the balance. All of these responsibilities should belong to the BankAccount itself!
The "Tell" Approach (Good)
public class BankAccount
{
private readonly string _accountNumber;
private decimal _balance;
private readonly decimal _minimumBalance;
private bool _isActive;
public BankAccount(string accountNumber, decimal initialBalance, bool isActive = true)
{
_accountNumber = accountNumber;
_balance = initialBalance;
_minimumBalance = 100m;
_isActive = isActive;
}
public void Withdraw(decimal amount)
{
ValidateWithdrawal(amount);
PerformWithdrawal(amount);
}
private void ValidateWithdrawal(decimal amount)
{
if (!_isActive)
{
throw new InvalidOperationException("Account is not active");
}
if (amount <= 0)
{
throw new ArgumentException("Amount must be positive");
}
if (_balance < amount)
{
throw new InvalidOperationException("Insufficient funds");
}
if (_balance - amount < _minimumBalance)
{
throw new InvalidOperationException(
$"Cannot withdraw: minimum balance of {_minimumBalance:C} required");
}
}
private void PerformWithdrawal(decimal amount)
{
_balance -= amount;
Console.WriteLine($"Withdrew {amount:C}. New balance: {_balance:C}");
}
public decimal GetBalance() => _balance; // Read-only for display
}
public class ATMService
{
public void WithdrawMoney(BankAccount account, decimal amount)
{
// TELL the account to withdraw
account.Withdraw(amount);
}
}
The BankAccount now encapsulates all withdrawal logic, validates its own state, protects its invariants (minimum balance, active status), and can evolve independently. The ATMService is now trivially simple—it just tells the account what to do.
Key Takeaway: Business rules about bank accounts should live in the BankAccount class, not scattered across services that use it.
Example 3: Employee Salary Calculation
The "Ask" Approach (Bad)
public class Employee
{
public string Name { get; set; }
public decimal HourlyRate { get; set; }
public int HoursWorked { get; set; }
public string EmployeeType { get; set; } // "FullTime" or "Contractor"
public bool HasHealthInsurance { get; set; }
}
public class PayrollService
{
public decimal CalculateSalary(Employee employee)
{
// VIOLATION: Asking for employee data
decimal baseSalary = employee.HourlyRate * employee.HoursWorked;
// VIOLATION: Checking employee type externally
decimal bonus = 0;
if (employee.EmployeeType == "FullTime")
{
bonus = baseSalary * 0.10m;
}
// VIOLATION: Checking insurance status externally
decimal insuranceDeduction = 0;
if (employee.HasHealthInsurance)
{
insuranceDeduction = 200m;
}
return baseSalary + bonus - insuranceDeduction;
}
}
The "Tell" Approach (Good)
public abstract class Employee
{
protected readonly string _name;
protected readonly decimal _hourlyRate;
protected int _hoursWorked;
protected Employee(string name, decimal hourlyRate)
{
_name = name;
_hourlyRate = hourlyRate;
}
public void RecordHoursWorked(int hours)
{
if (hours < 0)
throw new ArgumentException("Hours cannot be negative");
_hoursWorked += hours;
}
public SalaryBreakdown CalculateSalary()
{
decimal baseSalary = CalculateBaseSalary();
decimal bonus = CalculateBonus(baseSalary);
decimal deductions = CalculateDeductions();
return new SalaryBreakdown(_name, baseSalary, bonus, deductions);
}
protected virtual decimal CalculateBaseSalary()
{
return _hourlyRate * _hoursWorked;
}
protected abstract decimal CalculateBonus(decimal baseSalary);
protected abstract decimal CalculateDeductions();
}
public class FullTimeEmployee : Employee
{
private readonly bool _hasHealthInsurance;
public FullTimeEmployee(string name, decimal hourlyRate, bool hasHealthInsurance)
: base(name, hourlyRate)
{
_hasHealthInsurance = hasHealthInsurance;
}
protected override decimal CalculateBonus(decimal baseSalary)
{
return baseSalary * 0.10m; // 10% bonus
}
protected override decimal CalculateDeductions()
{
return _hasHealthInsurance ? 200m : 0;
}
}
public class ContractorEmployee : Employee
{
public ContractorEmployee(string name, decimal hourlyRate)
: base(name, hourlyRate)
{
}
protected override decimal CalculateBonus(decimal baseSalary) => 0;
protected override decimal CalculateDeductions() => 0;
}
public class PayrollService
{
public SalaryBreakdown ProcessPayroll(Employee employee)
{
// TELL the employee to calculate its salary
return employee.CalculateSalary();
}
}
Benefits: Each employee type encapsulates its own salary rules; adding a new employee type doesn't break existing code. PayrollService It is simple and focused, and it's easy to test each employee type independently.
Tell, Don't Ask with Other Principles
Tell, Don't Ask + SOLID
Single Responsibility Principle (SRP): When you tell objects what to do instead of asking them for data, each class naturally ends up with a single, clear responsibility.
Open/Closed Principle (OCP): The shopping cart example demonstrates this. We can add new discount strategies without modifying the ShoppingCart class.
Dependency Inversion Principle (DIP): Notice how ShoppingCart depends on ICustomerDiscountStrategy, an abstraction, not a concrete implementation.
Tell, Don't Ask + DRY
Tell, Don't Ask helps you follow DRY (Don't Repeat Yourself) by centralising logic. In our bad examples, validation and calculation logic was scattered. In our good examples, it exists in exactly one place.
Tell, Don't Ask + KISS
Tell, Don't Ask leads to simpler client code. Compare our "bad" CheckoutService with dozens of lines of logic to our "good" version with a single line: cart.CalculateTotal().
When to Violate Tell, Don't Ask
Like all principles, Tell, Don't Ask isn't absolute. There are legitimate times to "ask":
Data Transfer Objects (DTOs)
When transferring data across boundaries (e.g., from your domain to an API response), DTOs are often just data containers. This is fine—DTOs don't have behavior.
View Models
In ASP.NET MVC or Blazor, view models often expose properties for display. Again, this is acceptable. The view model's job is to present data.
Reporting and Analytics
When generating reports or analytics, you often need to query objects for their state. This is a legitimate use of querying.
Cross-Boundary Communication
When integrating with external systems, you may need to expose data. The gateway needs to ask for account details because it's external to your domain.
Rule of Thumb: Ask yourself: Does this logic belong to the object I'm querying, or does it truly belong to the caller? If it belongs to the object, refactor to Tell, Don't Ask. If it belongs to the caller, asking is fine.
Common Pitfalls
Pitfall 1: Over-Engineering
Don't create 20 classes just to avoid a simple getter. Sometimes, a simple property is fine.
Pitfall 2: Hiding Too Much
Don't make your objects so opaque that they're impossible to work with. Find a balance between encapsulation and usability.
Pitfall 3: Confusing Data Objects with Behavior Objects
Not every object needs rich behavior. Configuration objects, DTOs, and value objects can be data-centric.
Conclusion
The Tell, Don't Ask principle is about respecting the core tenets of object-oriented programming: encapsulation, single responsibility, and cohesion. When you tell objects what you want instead of interrogating them, you create code that is:
✅ Easier to maintain - Logic lives in the right places
✅ Easier to extend - New features don't require changing existing code
✅ Easier to test - Objects can be tested in isolation
✅ Easier to understand - Client code is simpler and more expressive
Practical Takeaways
Identify violations - Look for code that queries an object and then makes decisions based on that query
Move logic - Refactor decision-making into the object that owns the data
Create behavior methods - Replace getters with methods that do something useful
Use polymorphism - Let different object types handle their own behavior
Be pragmatic - Know when it's okay to ask (DTOs, view models, reporting)
Have you encountered situations where Tell, Don't Ask transformed your code? Share your experiences in the comments below!
Source Code
Complete, runnable source code for all examples: GitHub Repository Link
Related Articles