Loading learning content...
Consider a medical practice. Patient records are kept in filing cabinets—folders containing medical history, prescriptions, lab results. Now imagine if the doctors, nurses, and pharmacists who interpret and act on those records worked in completely separate buildings with no connection to where the records are stored.
Every time a doctor needed to make a decision, they'd have to send a messenger to the filing building, wait for records, interpret them, make a decision, then send another messenger to update the records. The potential for lost data, miscommunication, and errors would be enormous.
This is precisely what happens in software when we separate data from behavior.
The fundamental principle: Data and the operations that manipulate that data have a natural affinity. They belong together. This page explores why this bundling is essential, how to achieve it correctly, and what happens when we violate this principle.
By the end of this page, you will understand the principle of cohesive bundling, recognize the symptoms of data-behavior separation, design classes that keep related data and methods together, and understand the relationship between bundling and the Single Responsibility Principle.
In any system, certain operations are intrinsically tied to certain data. This isn't arbitrary—it reflects the nature of the domain we're modeling:
In each case, the operations are meaningless without the data, and the data is rarely useful without operations to manipulate it.
calculateTotal() method always needs access to the items and prices. They're inseparable.balance number means nothing without operations that explain its semantics (what currency? can it go negative?).items becomes lineItems, all operations accessing it must update.The object-oriented solution:
Object-oriented design formalizes this affinity. A class is a construct that packages:
This package becomes a single, cohesive unit—an object—that is self-sufficient for its domain responsibilities.
The most common violation of data-behavior bundling is the Anemic Domain Model—a term coined by Martin Fowler to describe a diseased design pattern that plagues enterprise software.
In an anemic domain model:
123456789101112131415161718192021222324252627282930313233343536373839404142
// ❌ ANTI-PATTERN: Anemic Domain Model // The "entity" is just a data bag with no behaviorclass Order { id: string; customerId: string; items: OrderItem[]; status: string; totalAmount: number; createdAt: Date; // Nothing but getters and setters} // All behavior lives in a separate "service"class OrderService { calculateTotal(order: Order): number { let total = 0; for (const item of order.items) { total += item.price * item.quantity; } return total; } canBeCancelled(order: Order): boolean { return order.status === 'pending' || order.status === 'processing'; } cancel(order: Order): void { if (!this.canBeCancelled(order)) { throw new Error("Order cannot be cancelled"); } order.status = 'cancelled'; // Direct field manipulation from outside the class! } addItem(order: Order, item: OrderItem): void { order.items.push(item); order.totalAmount = this.calculateTotal(order); // The service knows intimate details of Order's internals }}The problems with this design are severe:
order.status = 'invalid_garbage'.Many enterprise frameworks encourage anemic models by generating "entity" classes for data and expecting "service" classes for logic. This architecture pattern has legitimate uses (for coordination and orchestration), but becomes an anti-pattern when ALL domain logic migrates to services, leaving entities hollow.
The antidote to anemic models is the Rich Domain Model—objects that own both their data AND the behavior that operates on that data. Let's refactor the previous example:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
// ✅ CORRECT: Rich Domain Model with bundled behavior class Order { private readonly id: string; private readonly customerId: string; private items: OrderItem[]; private status: OrderStatus; private cachedTotal: number | null = null; private readonly createdAt: Date; constructor(id: string, customerId: string) { this.id = id; this.customerId = customerId; this.items = []; this.status = OrderStatus.PENDING; this.createdAt = new Date(); } // Behavior is bundled WITH the data it operates on addItem(item: OrderItem): void { if (!this.canBeModified()) { throw new OrderModificationError( "Cannot add items to a non-modifiable order" ); } this.validateItem(item); this.items.push(item); this.invalidateCachedTotal(); } removeItem(itemId: string): void { if (!this.canBeModified()) { throw new OrderModificationError( "Cannot remove items from a non-modifiable order" ); } this.items = this.items.filter(item => item.id !== itemId); this.invalidateCachedTotal(); } getTotal(): number { if (this.cachedTotal === null) { this.cachedTotal = this.items.reduce( (sum, item) => sum + item.price * item.quantity, 0 ); } return this.cachedTotal; } cancel(): void { if (!this.canBeCancelled()) { throw new OrderStateError( `Order in status ${this.status} cannot be cancelled` ); } this.status = OrderStatus.CANCELLED; } // Domain logic is INSIDE the class private canBeModified(): boolean { return this.status === OrderStatus.PENDING; } canBeCancelled(): boolean { return [OrderStatus.PENDING, OrderStatus.PROCESSING] .includes(this.status); } private validateItem(item: OrderItem): void { if (item.quantity <= 0) { throw new ValidationError("Item quantity must be positive"); } if (item.price < 0) { throw new ValidationError("Item price cannot be negative"); } } private invalidateCachedTotal(): void { this.cachedTotal = null; } // Controlled access to state getId(): string { return this.id; } getStatus(): OrderStatus { return this.status; } getItemCount(): number { return this.items.length; } // Return a defensive copy or immutable view getItems(): ReadonlyArray<OrderItem> { return [...this.items]; }}The transformation is dramatic:
Data-behavior bundling is deeply connected to the concept of cohesion—a measure of how strongly related and focused the responsibilities of a single module are.
High cohesion means that the elements of a class work together toward a single, well-defined purpose. Low cohesion means a class is a grab-bag of unrelated responsibilities.
Bundling data and behavior correctly is the mechanism by which we achieve high cohesion.
| Type | Description | Example | Quality |
|---|---|---|---|
| Coincidental | Elements are randomly grouped | UtilityClass with unrelated functions | ❌ Worst |
| Logical | Elements do similar things but on different data | AllValidatorsInOneClass | ❌ Poor |
| Temporal | Elements are grouped by when they execute | StartupInitializer doing unrelated setup | ⚠️ Weak |
| Procedural | Elements are steps in a procedure | ProcessingPipeline with coupled steps | ⚠️ Moderate |
| Communicational | Elements operate on the same data | Report class generating multiple reports from same data | ✅ Good |
| Sequential | Output of one element is input to next | DataTransformer pipeline | ✅ Good |
| Functional | All elements contribute to a single, well-defined task | Parser class parsing only one format | ✅ Best |
Cohesion and bundling:
When data and behavior are properly bundled, classes naturally achieve communicational or functional cohesion:
An Order class achieves functional cohesion when all its methods relate to order management. The data (items, status, total) and behavior (addItem, cancel, getTotal) work together toward managing orders.
Anemic models create coincidental or logical cohesion: the entity class groups data coincidentally, while service classes group behaviors logically but not by the data they operate on.
To test cohesion, try to describe your class's responsibility in one sentence without using "and" or "or." If you can't, the class might be doing too much. An Order class "manages order state and operations"—single responsibility. An OrderAndEmailService "manages orders AND sends emails"—two responsibilities, low cohesion.
Deciding what data and behavior to bundle together requires judgment. Here are practical heuristics for making these decisions:
startTime and endTime are always accessed by isActive() and getDuration(), they belong in the same class.1234567891011121314151617181920212223242526272829303132333435363738394041
// Applying the Information Expert Principle // ❌ WRONG: External calculationclass Product { name: string; price: number; taxCategory: string;} class TaxCalculator { calculateTax(product: Product): number { // TaxCalculator needs to know about Product's internals switch (product.taxCategory) { case 'food': return product.price * 0.0; case 'luxury': return product.price * 0.20; default: return product.price * 0.10; } }} // ✅ CORRECT: Product is the information expertclass Product { private name: string; private price: number; private taxRate: number; // Internalized from taxCategory constructor(name: string, price: number, taxCategory: TaxCategory) { this.name = name; this.price = price; this.taxRate = TaxCategory.toRate(taxCategory); } // Product knows its own tax—it has all needed information calculateTax(): number { return this.price * this.taxRate; } getPriceWithTax(): number { return this.price + this.calculateTax(); }}Boundaries are not always obvious:
Some decisions require deeper analysis:
The goal is not to cram everything into domain classes, but to ensure that domain behavior lives with domain data. Technical infrastructure remains separate.
Data-behavior bundling naturally leads to the Tell, Don't Ask principle—a design heuristic that reinforces good encapsulation:
Tell objects what to do; don't ask them for data and make decisions externally.
This principle directly addresses a common pattern where external code queries an object's state, makes a decision, then tells the object what to do. This pattern violates encapsulation because the decision logic should live inside the object.
123456789101112131415
// ❌ ASK: Query then decide externally function processPayment(order: Order) { // ASK for status if (order.getStatus() === 'pending') { // ASK for total const total = order.getTotal(); // External decision-making if (total > 0) { paymentGateway.charge(total); // TELL order what we decided order.setStatus('paid'); } }}1234567891011121314151617
// ✅ TELL: Let the object decide function processPayment(order: Order) { // TELL order to accept payment // Order decides if this is valid order.acceptPayment(paymentGateway); // Order encapsulates its own rules} // Inside Order class:acceptPayment(gateway: PaymentGateway) { if (this.status !== 'pending') { throw new InvalidStateError(); } gateway.charge(this.getTotal()); this.status = 'paid';}Why Tell, Don't Ask matters:
Encapsulates decisions — The decision "can I be paid?" lives inside Order, not scattered across callers.
Prevents duplication — Every caller would have to duplicate the status-checking logic in the Ask pattern.
Enables evolution — If the rules for accepting payment change (e.g., add fraud checks), only Order needs updating.
Reduces temporal coupling — In the Ask pattern, callers must call methods in the right order. In Tell, the object controls the sequence.
Note: This doesn't mean getters are forbidden. Sometimes you legitimately need to display data. But if you're getting data to make a decision, ask if that decision should live inside the object.
If you find yourself writing if (object.getSomething()) followed by object.doSomething(), consider whether the entire logic should be a single method on the object. The chain of "get-check-do" is a smell indicating potential encapsulation violation.
Let's work through a realistic example of designing bundled classes. Consider a Subscription system where users subscribe to plans with billing cycles.
Initial requirements:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
// A well-bundled Subscription class class Subscription { private readonly id: string; private readonly userId: string; private plan: Plan; private status: SubscriptionStatus; private startDate: Date; private currentPeriodEnd: Date; private pausedAt: Date | null = null; constructor(id: string, userId: string, plan: Plan) { this.id = id; this.userId = userId; this.plan = plan; this.status = SubscriptionStatus.ACTIVE; this.startDate = new Date(); this.currentPeriodEnd = this.plan.calculateNextBillingDate(this.startDate); } // ===== Domain Behaviors (bundled with data) ===== changePlan(newPlan: Plan, billingMode: BillingMode): PlanChangeResult { this.ensureCanModify(); const prorationData = this.calculateProration(newPlan, billingMode); const previousPlan = this.plan; this.plan = newPlan; this.currentPeriodEnd = newPlan.calculateNextBillingDate(new Date()); return new PlanChangeResult( previousPlan, newPlan, prorationData.creditAmount, prorationData.chargeAmount ); } pause(reason: PauseReason): void { this.ensureActive(); this.status = SubscriptionStatus.PAUSED; this.pausedAt = new Date(); } resume(): void { if (this.status !== SubscriptionStatus.PAUSED) { throw new InvalidStateError("Can only resume paused subscriptions"); } // Extend the period by the pause duration const pauseDuration = Date.now() - this.pausedAt!.getTime(); this.currentPeriodEnd = new Date( this.currentPeriodEnd.getTime() + pauseDuration ); this.status = SubscriptionStatus.ACTIVE; this.pausedAt = null; } cancel(immediate: boolean = false): CancellationResult { this.ensureCanModify(); if (immediate) { this.status = SubscriptionStatus.CANCELLED; return new CancellationResult(new Date(), this.calculateRefund()); } // Schedule cancellation at period end this.status = SubscriptionStatus.PENDING_CANCELLATION; return new CancellationResult(this.currentPeriodEnd, 0); } renew(): void { if (!this.isRenewable()) { throw new InvalidStateError("Subscription is not renewable"); } this.currentPeriodEnd = this.plan.calculateNextBillingDate( this.currentPeriodEnd ); this.status = SubscriptionStatus.ACTIVE; } // ===== Private Helpers (internal logic) ===== private ensureActive(): void { if (this.status !== SubscriptionStatus.ACTIVE) { throw new InvalidStateError( `Operation requires active subscription, current: ${this.status}` ); } } private ensureCanModify(): void { const modifiableStatuses = [ SubscriptionStatus.ACTIVE, SubscriptionStatus.PAUSED ]; if (!modifiableStatuses.includes(this.status)) { throw new InvalidStateError("Subscription cannot be modified"); } } private calculateProration(newPlan: Plan, mode: BillingMode): ProrationData { const daysRemaining = this.getDaysRemainingInPeriod(); const totalDays = this.getPeriodLengthDays(); const fraction = daysRemaining / totalDays; const currentCredit = this.plan.price * fraction; const newCharge = mode === BillingMode.IMMEDIATE ? newPlan.price : newPlan.price * fraction; return { creditAmount: currentCredit, chargeAmount: newCharge }; } private calculateRefund(): number { const fraction = this.getDaysRemainingInPeriod() / this.getPeriodLengthDays(); return this.plan.price * fraction; } // ===== Query Methods (controlled access) ===== isActive(): boolean { return this.status === SubscriptionStatus.ACTIVE; } isRenewable(): boolean { return [ SubscriptionStatus.ACTIVE, SubscriptionStatus.PAUSED ].includes(this.status); } getDaysRemainingInPeriod(): number { /* ... */ } getPeriodLengthDays(): number { /* ... */ } getCurrentPlan(): Plan { return this.plan; }}Notice how the design embodies bundling:
subscription.changePlan(newPlan)), not howAn anemic version would expose all fields and put SubscriptionService.changePlan(subscription, newPlan) elsewhere—losing all these benefits.
We've explored the first pillar of encapsulation in depth. Here are the essential takeaways:
What's next:
Bundling is only half of encapsulation. The next page explores the second pillar: hiding implementation details. We'll learn how to decide what to expose, what to hide, and why keeping secrets is essential for maintainable software.
You now understand why data and behavior belong together, can recognize the anemic domain model anti-pattern, and know how to design rich domain models that properly bundle state and operations. This is the foundation of writing objects that are self-sufficient and self-protecting.