Your OrderService is probably a mess of if statements. It checks order.Status, then order.PaymentMethod, then digs into order.Items.Count before it can figure out what to do next. Sound familiar?

We’ve all written code like this. And it works fine, until it doesn’t. This pattern burned me once in production. A new PendingValidation status was added to our Order object, but someone forgot to update the CancellationService. Boom. We suddenly couldn’t cancel any orders stuck in that state. The logic was in the wrong place.

The problem is asking an object for its state and then making decisions outside that object. There’s a better way: just tell the object what to do and let it handle its own business.

TL;DR

  • Stop micromanaging: Tell objects what outcome you want (order.RequestCancellation()), don’t ask for their state (if (order.Status == ...)).
  • Cut down on coupling: Decision logic should live inside the object that owns the data. Your services will get a lot simpler.
  • Make testing easier: You can test an object’s behavior directly instead of setting up a ton of state just to test a service method.
  • Write cleaner code: Services should coordinate tasks. Domain objects should handle their own business rules. That’s it.
  • Don’t overdo it: Simple property getters are fine. You don’t need to wrap customer.Name in a method just to be a purist. This is about behavior, not just data.

The Code Smell: Services Playing Twenty Questions

When one object constantly interrogates another about its internal state, you create a nasty dependency. The calling object has to know way too much about how the other one works.

Here’s a classic anti-pattern. The OrderProcessor is way too nosy.

public class OrderProcessor
{
    public void ProcessOrder(Order order)
    {
        // Bad: Asking the order about its state and deciding for it.
        if (order.Status == OrderStatus.Pending && 
            order.Items.Any() && 
            order.PaymentMethod != null)
        {
            order.Status = OrderStatus.Processing;
            order.ProcessedAt = DateTime.UtcNow;
            // ...more logic that should be in the Order class
        }
    }
}

Every time a rule about processing an order changes, you have to change OrderProcessor. But the rules for processing an order are part of the Order’s world, not the processor’s. The logic has leaked out.

Lesson Learned: I once had to debug a system where the rules for a valid order were scattered across six different service classes. A bug fix in one place would break another. It was a painful reminder to keep business logic contained.

Let’s look at another one. This CheckoutService thinks it’s in charge of discount logic.

// The anti-pattern: This service knows too much.
public class CheckoutService
{
    public CheckoutResult ProcessCheckout(ShoppingCart cart)
    {
        // Asking the cart for its state and deciding externally
        if (cart.IsEmpty)
        {
            return new CheckoutResult { Success = false, Message = "Cart is empty" };
        }

        // All this discount logic belongs in the ShoppingCart!
        if (cart.CustomerType == CustomerType.Premium && cart.Total > 100)
        {
            cart.Total *= 0.9m; // 10% discount
        }
        else if (cart.CustomerType == CustomerType.Regular && cart.Total > 200)
        {
            cart.Total *= 0.95m; // 5% discount
        }

        // ...more external logic
        return new CheckoutResult { Success = true };
    }
}

This code is brittle. What happens when you add a Gold customer tier? You have to go hunt down this CheckoutService and add another else if. It’s a recipe for bugs.

The Fix: Tell Your Objects What to Do

Let’s refactor this. The CheckoutService shouldn’t be making decisions. It should just tell the ShoppingCart to do its job.

public class ShoppingCart
{
    private readonly List<CartItem> _items = new();
    private readonly IPricingService _pricingService; // Dependencies are fine!

    public CustomerType CustomerType { get; init; }

    // Tell the cart to do the checkout. It figures out the details.
    public CheckoutResult AttemptCheckout()
    {
        if (IsEmpty())
        {
            return CheckoutResult.Failed("Cart is empty");
        }

        var finalTotal = CalculateFinalTotal();
        
        // The cart is responsible for its own rules.
        return CheckoutResult.Success(finalTotal);
    }

    private bool IsEmpty() => !_items.Any();
    
    // The cart knows how to calculate its total, maybe with help.
    private decimal CalculateFinalTotal()
    {
        var baseTotal = _items.Sum(item => item.Total);
        return _pricingService.ApplyDiscounts(baseTotal, CustomerType);
    }
    
    // ... other methods like AddItem, RemoveItem
}

public class CheckoutService
{
    public CheckoutResult ProcessCheckout(ShoppingCart cart)
    {
        // Clean. The service just coordinates.
        return cart.AttemptCheckout();
    }
}

Look at that CheckoutService now. It’s dead simple. It has one job: to kick off the checkout process. All the messy business logic about discounts and empty carts is tucked away inside the ShoppingCart, where it belongs.

Does This Wreck My EF Core Entities?

Nope. In fact, this pattern works great with EF Core. I use it all the time to build rich domain models that are still easy to persist.

Your entity class can hold the business logic, and EF Core’s change tracker is smart enough to pick up the changes.

public class Account
{
    public int Id { get; private set; }
    public decimal Balance { get; private set; }
    public AccountStatus Status { get; private set; }

    // Tell the account to withdraw money. Don't ask for its balance first.
    public WithdrawalResult ProcessWithdrawal(decimal amount)
    {
        if (Status != AccountStatus.Active)
        {
            return WithdrawalResult.Failed("Account is not active.");
        }
        
        if (Balance < amount)
        {
            return WithdrawalResult.Failed("Insufficient funds.");
        }

        Balance -= amount; // EF Core will track this change.
        return WithdrawalResult.Success(Balance);
    }
    
    // ... other methods
}

public class BankingService
{
    private readonly ApplicationDbContext _context;
    
    // ... constructor ...

    public async Task<WithdrawalResult> WithdrawAsync(int accountId, decimal amount)
    {
        var account = await _context.Accounts.FindAsync(accountId);
        if (account is null) { /* handle not found */ }
        
        // Just tell the account object to do its thing.
        var result = account.ProcessWithdrawal(amount);
        
        if (result.Success)
        {
            // Only save if the business operation succeeded.
            await _context.SaveChangesAsync();
        }
        
        return result;
    }
}

Production Tip: This is a solid pattern for database operations. The business rule validation happens in the entity, and you only call SaveChangesAsync() if the operation is valid. This prevents you from saving an invalid state to the database. It burned me once when a junior dev saved changes before validating, leaving corrupted data after an exception was thrown.

Where This Pattern Goes Wrong

Like any principle, you can take it too far. I’ve seen teams create some weird, over-engineered code by misapplying this. Here are the gotchas to watch out for.

1. Over-Encapsulation

Don’t hide simple properties for no reason. If you just need to display a customer’s name, customer.Name is perfectly fine. Creating a customer.GetName() method is just noise. This principle is for encapsulating behavior, not hiding all data.

// Good enough. It's just data.
public class Customer
{
    public string Name { get; init; }
    public string Email { get; private set; }
}

// Bad. This is pointless ceremony.
public class Customer
{
    private string _name;
    public string GetName() => _name; // Why?
}

2. Bloated Objects

Your Order object shouldn’t be responsible for sending emails or processing payments. That violates the Single Responsibility Principle. The Order object can initiate those things, but the actual work should be done by dedicated services.

The Order tells an IPaymentService to process a payment; it doesn’t contain the Stripe API client itself.

My Recommendation: When to Use It

This isn’t an all-or-nothing rule. It’s a guideline for writing cleaner, more maintainable object-oriented code.

Use “Tell, Don’t Ask” when:

  • You’re dealing with business rules that validate or change an object’s state (e.g., cancelling an order, withdrawing from an account).
  • Multiple external classes are all checking the same properties on an object to make decisions.
  • You want your service classes to be simple coordinators, not tangled messes of business logic.

It’s probably overkill when:

  • You’re just reading data for a view model or DTO. There’s no behavior to encapsulate.
  • You’re working with cross-cutting concerns like logging or authorization, which are often better handled by middleware or attributes.

Start looking for this pattern in your next code review. If you see a service method that’s mostly if statements checking another object’s properties, you’ve found a great candidate for refactoring. Move that logic into the object that owns the data. Your future self will thank you for it.

FAQ

Isn’t this just putting all my code into my domain models?

No. It’s about putting the logic that governs an object’s own state and rules inside that object. Logic for coordinating between objects or interacting with external systems (databases, APIs, email) still belongs in services.

Does this make my objects harder to test?

It makes them easier to test. You can unit test the Account.ProcessWithdrawal method directly by creating an Account object, calling the method, and asserting the result. You don’t need to mock a dozen dependencies just to test one piece of business logic.

Can I use this with an anemic domain model?

This principle is the direct opposite of an anemic domain model. Anemic models are just property bags with no behavior. The “Tell, Don’t Ask” principle encourages you to build rich domain models where data and behavior live together.

References

Additional Reading

Subscribe for Updates

Related Posts