Loading content...
Consider this question: Where does the logic for calculating a rectangle's area belong?
Option A — In a GeometryService that takes a rectangle and computes its area:
const area = geometryService.calculateArea(rectangle);
Option B — In the Rectangle itself:
const area = rectangle.getArea();
Most developers instinctively choose Option B. The rectangle knows its own width and height—why should some external service calculate its area? The data lives in Rectangle, so the behavior should too.
This intuition captures a fundamental principle: behavior should gravitate to where the data it operates on resides. Yet in complex systems, this intuition gets buried under layers of 'service' classes, 'manager' objects, and 'utility' helpers—all of which pull behavior away from data.
By the end of this page, you will understand why behavior separated from its data creates maintenance nightmares, how to identify misplaced behavior in existing code, systematic techniques for relocating logic to where it belongs, and how proper co-location of behavior and data creates cohesive, maintainable objects.
At the heart of object-oriented programming lies a simple but profound idea: objects bundle data and the behavior that operates on that data together. This bundling isn't arbitrary—it's the mechanism that makes encapsulation possible.
When behavior lives with its data:
When behavior is separated from its data:
The GRASP principle 'Information Expert' formalizes this: assign responsibility to the class that has the information needed to fulfill it. If an object has the data, it should have the behavior. If behavior requires data from multiple objects, assign it to the object with the most relevant information—or create a new abstraction that bundles the needed data together.
The Cohesion Dimension:
'Pushing behavior to data' is another way of saying 'increase cohesion.' High cohesion means a class's methods and properties are strongly related—they all revolve around a unified concept.
When you pull behavior away from data:
When you push behavior to data:
Before you can push behavior to where it belongs, you need to recognize when it's in the wrong place. Here are the diagnostic patterns that reveal misplaced behavior:
OrderService contains business rules about orders, that logic belongs in Order.StringHelper.capitalize(string) suggest String should have a capitalize() method (many languages now do).calculateTax(order) suggests order.calculateTax() would be more appropriate.if (user.getRole() === 'admin') scattered throughout suggests user.hasAdminPermission() or polymorphism.UserManager or OrderHandler often indicate behavior stolen from User and Order.The Locality Test:
To diagnose whether behavior is misplaced, apply this test:
If a method primarily uses data from another class, that's a strong signal for relocation. The goal is for each method to primarily use data from its own class.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Misplaced: Behavior is in the wrong placeclass DiscountService { calculateDiscount(customer: Customer): number { // This method uses customer data extensively const totalSpent = customer.getTotalSpent(); const memberSince = customer.getMemberSince(); const tier = customer.getTier(); // Logic based entirely on Customer's data if (tier === 'gold') { return 0.2; // 20% discount } else if (tier === 'silver') { return 0.1; // 10% discount } else if (totalSpent > 10000) { return 0.05; // 5% discount for high spenders } else if (this.yearsAsMember(memberSince) > 5) { return 0.05; // 5% loyalty discount } return 0; } private yearsAsMember(date: Date): number { // More logic that should live with Customer or a Date abstraction return /* calculation */; }} // Behavior pushed to where data livesclass Customer { private totalSpent: number; private memberSince: Date; private tier: CustomerTier; getDiscount(): number { // Customer knows its own discount rules if (this.tier === CustomerTier.Gold) { return 0.2; } else if (this.tier === CustomerTier.Silver) { return 0.1; } else if (this.totalSpent > 10000) { return 0.05; } else if (this.yearsAsMember() > 5) { return 0.05; } return 0; } private yearsAsMember(): number { // Encapsulated - Customer calculates its own tenure const now = new Date(); return now.getFullYear() - this.memberSince.getFullYear(); }}Moving behavior to where it belongs is often called the Move Method refactoring. It's one of the most common and valuable refactorings in object-oriented development. Here's a systematic approach:
target.getData() with direct field access (since you're now inside the target)return target.newMethod()When the Target Isn't Obvious:
Sometimes a method uses data from multiple classes equally. In these cases:
There's rarely a single correct answer—design involves judgment. But the goal remains: minimize the extraction of data, maximize delegation of behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Before: Method in ReportGenerator uses mostly Order dataclass ReportGenerator { generateOrderSummary(order: Order): string { const items = order.getItems(); const customer = order.getCustomer(); const total = order.getTotal(); const date = order.getDate(); let summary = `Order from ${customer.getName()} on ${date}`; summary += items.map(i => `- ${i.getProduct().getName()}: $${i.getTotal()}` ).join(''); summary += `Total: $${total}`; return summary; }} // After Step 1-3: Identify that Order is the target // After Step 4-5: Method moved to Orderclass Order { private items: OrderItem[]; private customer: Customer; private total: number; private date: Date; generateSummary(): string { // Direct field access instead of getters let summary = `Order from ${this.customer.getName()} on ${this.date}`; summary += this.items.map(item => `- ${item.getProductName()}: $${item.getTotal()}` ).join(''); summary += `Total: $${this.total}`; return summary; }} // After Step 6-7: Original can delegate or be removedclass ReportGenerator { generateOrderSummary(order: Order): string { // Now just delegates return order.generateSummary(); } // ... other report methods that aren't order-specific}Don't try to relocate all misplaced behavior at once. Move one method, ensure tests pass, then move the next. Incremental refactoring reduces risk and allows you to learn as you go. Each successful move builds confidence and improves the design.
The ultimate consequence of not pushing behavior to data is the Anemic Domain Model anti-pattern, identified by Martin Fowler as "one of those anti-patterns that's been around for quite a long time, yet seems to be having a particular spurt at the moment."
In an anemic domain model:
The Rich Domain Model is the opposite:
1234567891011121314151617181920212223242526272829303132333435363738
// Anemic Domain Modelclass Account { // Just data - no behavior private balance: number; private status: string; private overdraftLimit: number; getBalance(): number { return this.balance; } setBalance(b: number) { this.balance = b; } getStatus(): string { return this.status; } setStatus(s: string) { this.status = s; } getOverdraftLimit(): number { return this.overdraftLimit; }} // All logic in serviceclass AccountService { withdraw(account: Account, amount: number) { if (account.getStatus() !== 'active') { throw new Error('Inactive account'); } const balance = account.getBalance(); const limit = account.getOverdraftLimit(); if (balance - amount < -limit) { throw new Error('Overdraft exceeded'); } account.setBalance(balance - amount); }}123456789101112131415161718192021222324252627282930313233343536373839
// Rich Domain Modelclass Account { private balance: number; private status: AccountStatus; private overdraftLimit: number; // Behavior WITH the data withdraw(amount: number): void { this.ensureActive(); this.ensureWithinLimit(amount); this.balance -= amount; } private ensureActive(): void { if (this.status !== AccountStatus.Active) { throw new InactiveAccountError(); } } private ensureWithinLimit(amount: number): void { if (this.balance - amount < -this.overdraftLimit) { throw new OverdraftLimitExceededError(); } } // Getters only when truly needed externally getBalance(): number { return this.balance; }} // Service coordinates, doesn't contain logicclass AccountService { withdraw(accountId: string, amount: number) { const account = this.repository.find(accountId); account.withdraw(amount); // Tell! this.repository.save(account); }}Why Anemic Models Persist:
Despite being an anti-pattern, anemic models are common. Why?
The problem is that as systems grow complex, anemic models amplify that complexity—logic spreads, duplication multiplies, and the domain becomes incomprehensible.
Anemic models seem simple initially. But they pay interest over time: every new feature requires understanding which service has which logic, duplicated validations appear everywhere, and the 'domain' exists only in developers' heads—not in the code. Rich models front-load design effort but compound returns over time.
A common source of confusion is how 'pushing behavior to data' interacts with layered architectures. If domain objects have behavior, what do services do? Let's clarify the appropriate role of each layer:
| Layer | Appropriate Responsibilities | NOT Appropriate |
|---|---|---|
| Domain Objects | Business rules, validations, state transitions, domain calculations, invariant enforcement | Persistence, external service calls, UI concerns, cross-cutting concerns |
| Application Services | Use case orchestration, transaction management, security checks, calling domain methods, coordinating between aggregates | Business rules, domain calculations—these belong in domain objects |
| Infrastructure | Persistence implementation, external APIs, messaging, file systems | Business logic—infrastructure is mechanistic, not intelligent |
| Presentation | UI rendering, input handling, data formatting for display | Business decisions—these should be delegated to domain |
The Service Layer's True Role:
In a rich domain model, services become thin orchestrators rather than fat logic containers. They:
Services answer 'what use cases exist?' but domain objects answer 'what are the business rules?'
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// A properly thin application serviceclass OrderApplicationService { constructor( private orderRepository: OrderRepository, private inventoryService: InventoryService, private eventPublisher: EventPublisher ) {} async processOrder(orderId: string): Promise<void> { // 1. Retrieve the domain object const order = await this.orderRepository.findById(orderId); if (!order) { throw new OrderNotFoundError(orderId); } // 2. Tell the domain object what to do (business logic is inside) order.process(); // Order knows how to process itself // 3. Tell other services (they know their logic too) await this.inventoryService.reserveItems(order.getItems()); // 4. Persist the result await this.orderRepository.save(order); // 5. Handle cross-cutting concerns await this.eventPublisher.publish(new OrderProcessedEvent(order)); }} // Order contains the real business logicclass Order { private status: OrderStatus; private items: OrderItem[]; private processedAt?: Date; process(): void { // Business rule: can only process pending orders if (this.status !== OrderStatus.Pending) { throw new InvalidOrderStateError( `Cannot process order in status ${this.status}` ); } // Business rule: must have items if (this.items.length === 0) { throw new EmptyOrderError(); } // State transition with invariant enforcement this.status = OrderStatus.Processing; this.processedAt = new Date(); // Tell items to process themselves this.items.forEach(item => item.markProcessing()); }}This layering follows Domain-Driven Design principles. The domain layer is the heart of the application, containing the 'ubiquitous language' and business rules. Application services exist to support the domain, not to contain the logic themselves. Infrastructure serves both. When behavior is pushed to data, DDD emerges naturally.
Deciding where behavior belongs isn't always straightforward. Here are practical heuristics to guide your decisions:
order.calculateTotal(). 'A calculator calculates order totals' suggests anemia.Order, that's intuitive.Handling Multi-Object Behavior:
Some behavior genuinely involves multiple objects. For example, 'transfer money between accounts' involves two accounts. Options include:
sourceAccount.transferTo(targetAccount, amount) — Source 'owns' the transfer actionnew Transfer(source, target, amount).execute() — Transfer encapsulates the operationbankingService.transfer(source, target, amount) — Appropriate when no single object makes senseEven with domain services, preference goes to delegating: the service uses source.withdraw() and target.deposit() rather than manipulating account internals directly.
If you're unsure, pick a reasonable location and continue. As you develop more of the system, misplacement becomes obvious: you'll see feature envy, duplication, or awkward dependencies. Then refactor. Good tests make relocation safe. Don't let analysis paralysis prevent progress.
Let's examine common patterns for pushing behavior to data in real codebases. These patterns appear repeatedly across domains and provide templates for refactoring:
Problem: External code checks object type and branches accordingly.
Solution: Push behavior into subclasses and use polymorphism.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Before: Conditional based on typeclass PaymentProcessor { process(payment: Payment): void { if (payment.getType() === 'credit') { // Credit card logic this.validateCard(payment.getCardNumber()); this.chargeCard(payment.getCardNumber(), payment.getAmount()); } else if (payment.getType() === 'bank') { // Bank transfer logic this.validateAccount(payment.getBankAccount()); this.transfer(payment.getBankAccount(), payment.getAmount()); } else if (payment.getType() === 'crypto') { // Crypto logic this.validateWallet(payment.getWalletAddress()); this.sendCrypto(payment.getWalletAddress(), payment.getAmount()); } }} // After: Polymorphism - each payment knows how to process itselfabstract class Payment { protected amount: number; abstract process(): void;} class CreditCardPayment extends Payment { private cardNumber: string; process(): void { this.validateCard(); this.chargeCard(); } private validateCard(): void { /* ... */ } private chargeCard(): void { /* ... */ }} class BankTransferPayment extends Payment { private bankAccount: string; process(): void { this.validateAccount(); this.transfer(); } private validateAccount(): void { /* ... */ } private transfer(): void { /* ... */ }} // Processor now just tellsclass PaymentProcessor { process(payment: Payment): void { payment.process(); // Polymorphism does the rest }}We've explored the principle of co-locating behavior with data. Let's consolidate the key insights:
What's Next:
Now that we understand both the interaction style (tell, don't ask) and the placement principle (behavior with data), we'll see Tell, Don't Ask in action through concrete examples. We'll examine real-world scenarios across different domains and see how TDA transforms code from procedural to truly object-oriented.
You now understand why behavior should live where data lives, how to identify misplaced behavior, and systematic techniques for relocating logic to appropriate objects. You can distinguish rich from anemic domain models and know how layered architectures work with behavior-rich objects. Next, we'll see these principles applied in real-world examples.