Loading learning content...
We've explored entities as identifiable things and attributes as their descriptive properties. But if entities were just containers of data, they'd be little more than fancy database rows. The power of object-oriented design lies in making entities active participants in your system—capable of performing actions, enforcing rules, and managing their own state.
These capabilities are expressed through behaviors: the methods that define what an entity can DO, not just what it IS. Well-designed behaviors are the difference between code that merely stores data and code that models a living, breathing domain.
By the end of this page, you will understand what behaviors are, how to identify appropriate behaviors for entities, how behaviors interact with attributes and invariants, and the principles of designing cohesive, behavior-rich entities. You'll learn to create entities that actively participate in domain logic rather than passively holding data.
Behaviors are the actions, operations, and capabilities that an entity can perform. In code, behaviors manifest as methods—functions attached to the entity class that operate on the entity's data and/or produce effects.
Behaviors answer questions like:
Notice that behaviors are verbs while entities are nouns. This verb-noun pairing is the essence of object-oriented modeling.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
class BankAccount { private readonly id: AccountId; private readonly ownerId: CustomerId; private balance: Money; private status: AccountStatus; private readonly transactions: Transaction[] = []; // === BEHAVIORS === /** * Deposit money into the account */ deposit(amount: Money, description: string): void { this.ensureActive(); if (amount.isNegativeOrZero()) { throw new InvalidAmountError("Deposit amount must be positive"); } this.balance = this.balance.add(amount); this.transactions.push( Transaction.credit(amount, description, new Date()) ); } /** * Withdraw money from the account */ withdraw(amount: Money, description: string): void { this.ensureActive(); if (amount.isNegativeOrZero()) { throw new InvalidAmountError("Withdrawal amount must be positive"); } if (this.balance.lessThan(amount)) { throw new InsufficientFundsError(this.id, amount, this.balance); } this.balance = this.balance.subtract(amount); this.transactions.push( Transaction.debit(amount, description, new Date()) ); } /** * Transfer money to another account */ transferTo(destination: BankAccount, amount: Money): void { // This account's behavior orchestrates both sides this.withdraw(amount, `Transfer to ${destination.id}`); destination.deposit(amount, `Transfer from ${this.id}`); } /** * Freeze the account (suspends all operations) */ freeze(reason: string): void { if (this.status === AccountStatus.Closed) { throw new InvalidOperationError("Cannot freeze closed account"); } this.status = AccountStatus.Frozen; } // Helper to enforce account is usable private ensureActive(): void { if (this.status !== AccountStatus.Active) { throw new AccountNotActiveError(this.id, this.status); } }}Simple getters and setters are not behaviors—they're data access. A behavior embodies business logic: validation, state transitions, invariant enforcement, and meaningful operations. withdraw(amount) is a behavior; setBalance(value) is not. The former understands the domain; the latter just manipulates data.
Just as attributes can be categorized, behaviors fall into recognizable patterns. Understanding these categories helps you design complete, well-organized entities:
| Category | Purpose | Examples | Design Notes |
|---|---|---|---|
| State Transitions | Move entity through its lifecycle | confirm(), cancel(), publish(), archive() | Validate preconditions; often irreversible or controlled |
| Commands | Perform an action that changes state | deposit(), addItem(), assign(), update() | Validate inputs; enforce invariants; may trigger events |
| Queries | Retrieve information without side effects | getBalance(), isEligible(), canPerform() | Should not modify state; can be called repeatedly safely |
| Factories | Create related entities or value objects | createInvoice(), spawnChild(), clone() | Often return new objects; may set up relationships |
| Calculations | Compute derived values from state | calculateTotal(), age(), daysUntilExpiry() | Pure functions of current state; no side effects |
| Validators | Check if conditions or operations are valid | canShip(), isValid(), hasAccess() | Return booleans; use before allowing operations |
| Event Producers | Signal that something happened | recordActivity(), emit(), notify() | Create domain events; don't dictate handling |
Command-Query Separation (CQS)
A principle worth noting: ideally, methods should either be commands (do something, return nothing/void) or queries (return something, do nothing). Mixing both makes code harder to reason about.
deposit(amount) → Command: changes state, returns voidgetBalance() → Query: returns data, changes nothingwithdraw(amount): Balance → Violates CQS: changes state AND returns new balanceThe third pattern isn't terrible, but be conscious when you mix command and query. It makes it harder to call methods safely without side effects.
Sometimes returning a result from a command is pragmatic—like returning the created entity from a factory method. The principle exists to make you conscious of side effects. Violate it deliberately when practical, not accidentally.
Just as we identified entities by looking for nouns and attributes by looking for adjectives, we identify behaviors by looking for verbs in requirements—especially verbs that have the entity as their subject.
Here's a systematic approach to behavior discovery:
Worked Example: Order Behaviors
Requirement: "Customers can add items to their order, apply discount codes, confirm the order for payment. Confirmed orders can be paid, shipped, and delivered. Orders can be cancelled before shipping."
Let's extract behaviors:
| Phrase | Entity | Potential Behavior | Notes |
|---|---|---|---|
| "add items to their order" | Order | addItem(product, quantity) | Command that modifies order items |
| "apply discount codes" | Order | applyDiscount(code) | Validates and applies discount |
| "confirm the order" | Order | confirm() | State transition: Draft → Confirmed |
| "orders can be paid" | Order | recordPayment(paymentId) | State transition or payment association |
| "shipped" | Order | ship(trackingNumber) | State transition: Paid → Shipped |
| "delivered" | Order | markDelivered() | State transition: Shipped → Delivered |
| "cancelled before shipping" | Order | cancel(reason) | State transition with precondition check |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
enum OrderStatus { Draft = "DRAFT", Confirmed = "CONFIRMED", Paid = "PAID", Shipped = "SHIPPED", Delivered = "DELIVERED", Cancelled = "CANCELLED",} class Order { private readonly id: OrderId; private readonly customerId: CustomerId; private items: OrderItem[] = []; private status: OrderStatus = OrderStatus.Draft; private discountCode?: DiscountCode; private paymentId?: PaymentId; private trackingNumber?: string; // === BEHAVIORS === addItem(product: Product, quantity: number): void { this.ensureStatus(OrderStatus.Draft, "Cannot modify confirmed order"); const existing = this.items.find(i => i.productId === product.id); if (existing) { existing.increaseQuantity(quantity); } else { this.items.push(OrderItem.create(product, quantity)); } } removeItem(productId: ProductId): void { this.ensureStatus(OrderStatus.Draft, "Cannot modify confirmed order"); this.items = this.items.filter(i => i.productId !== productId); } applyDiscount(code: DiscountCode): void { this.ensureStatus(OrderStatus.Draft, "Cannot apply discount to confirmed order"); if (!code.isValid()) { throw new InvalidDiscountError(code); } if (!code.isApplicable(this)) { throw new DiscountNotApplicableError(code, this.id); } this.discountCode = code; } confirm(): void { this.ensureStatus(OrderStatus.Draft, "Order already confirmed"); if (this.items.length === 0) { throw new EmptyOrderError(this.id); } this.status = OrderStatus.Confirmed; } recordPayment(paymentId: PaymentId): void { this.ensureStatus(OrderStatus.Confirmed, "Cannot pay non-confirmed order"); this.paymentId = paymentId; this.status = OrderStatus.Paid; } ship(trackingNumber: string): void { this.ensureStatus(OrderStatus.Paid, "Cannot ship unpaid order"); this.trackingNumber = trackingNumber; this.status = OrderStatus.Shipped; } markDelivered(): void { this.ensureStatus(OrderStatus.Shipped, "Cannot deliver unshipped order"); this.status = OrderStatus.Delivered; } cancel(reason: string): void { if (this.status === OrderStatus.Shipped || this.status === OrderStatus.Delivered) { throw new CannotCancelShippedOrderError(this.id); } if (this.status === OrderStatus.Cancelled) { throw new OrderAlreadyCancelledError(this.id); } this.status = OrderStatus.Cancelled; // Might also trigger refund logic, emit events, etc. } // Query behaviors get total(): Money { const subtotal = this.items.reduce( (sum, item) => sum.add(item.subtotal), Money.zero() ); return this.discountCode ? this.discountCode.apply(subtotal) : subtotal; } canBeCancelled(): boolean { return this.status !== OrderStatus.Shipped && this.status !== OrderStatus.Delivered && this.status !== OrderStatus.Cancelled; } private ensureStatus(expected: OrderStatus, message: string): void { if (this.status !== expected) { throw new InvalidOrderStateError(message); } }}When an entity has many state transitions, sketch the state machine diagram. It reveals which transitions are valid and helps you implement guard conditions. Draft→Confirmed→Paid→Shipped→Delivered, with Cancelled accessible from Draft, Confirmed, and Paid only.
Designing good behaviors requires more than just implementing what the requirements say. Here are principles that lead to maintainable, robust behavior design:
order.ship(tracking) not if (order.status === 'PAID') order.setStatus('SHIPPED').confirm() not setStatusToConfirmed(). suspend() not setActive(false). Names matter.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// ❌ ASK — Caller interrogates object and makes decisionsfunction processOrder(order: Order, payment: Payment): void { // Caller has to know all the rules! if (order.getStatus() === OrderStatus.Confirmed) { if (payment.getAmount().equals(order.getTotal())) { if (payment.isSuccessful()) { order.setPaymentId(payment.getId()); order.setStatus(OrderStatus.Paid); order.setPaidAt(new Date()); } } }}// Problems: // - Rules scattered across codebase// - Easy to forget a check in some caller// - Order internals exposed everywhere// - Testing requires many integration scenarios // ✅ TELL — Caller tells object what to do; object knows howfunction processOrder(order: Order, payment: Payment): void { // Caller only knows the high-level intent order.recordPayment(payment);} class Order { recordPayment(payment: Payment): void { // All rules encapsulated here if (this.status !== OrderStatus.Confirmed) { throw new InvalidOrderStateError("Order must be confirmed"); } if (!payment.getAmount().equals(this.total)) { throw new PaymentAmountMismatchError(payment.getAmount(), this.total); } if (!payment.isSuccessful()) { throw new PaymentFailedError(payment); } this.paymentId = payment.getId(); this.status = OrderStatus.Paid; this.paidAt = new Date(); }}// Benefits:// - Rules in one place// - Entity protects its invariants// - Callers can't bypass checks// - Easy to test the entity in isolationAn "anemic domain model" has entities with lots of attributes but no behaviors—all logic lives in services/managers/helpers. This scatters domain knowledge, duplicates validation, and makes the codebase harder to understand. Rich entities with encapsulated behaviors are the antidote.
Behaviors and state are deeply intertwined. Behaviors modify state, and current state often determines which behaviors are valid. This relationship is captured in state machines—a powerful model for entity design.
A state machine defines:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Explicit state machine implementationenum TicketStatus { Open = "OPEN", InProgress = "IN_PROGRESS", OnHold = "ON_HOLD", Resolved = "RESOLVED", Closed = "CLOSED",} // Define valid transitionsconst VALID_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = { [TicketStatus.Open]: [TicketStatus.InProgress, TicketStatus.Closed], [TicketStatus.InProgress]: [TicketStatus.OnHold, TicketStatus.Resolved, TicketStatus.Open], [TicketStatus.OnHold]: [TicketStatus.InProgress, TicketStatus.Closed], [TicketStatus.Resolved]: [TicketStatus.Closed, TicketStatus.Open], // Can reopen! [TicketStatus.Closed]: [], // Terminal state}; class SupportTicket { private readonly id: TicketId; private status: TicketStatus = TicketStatus.Open; private assigneeId?: UserId; private resolution?: string; // State transition behaviors with guards startProgress(assigneeId: UserId): void { this.transitionTo(TicketStatus.InProgress); this.assigneeId = assigneeId; } putOnHold(reason: string): void { this.transitionTo(TicketStatus.OnHold); // Might record hold reason, notify watchers, etc. } resolve(resolution: string): void { if (!resolution || resolution.trim().length === 0) { throw new ValidationError("Resolution description required"); } this.transitionTo(TicketStatus.Resolved); this.resolution = resolution; } close(): void { this.transitionTo(TicketStatus.Closed); } reopen(): void { // Special: reopen from Resolved or return to Open from InProgress if (this.status === TicketStatus.Resolved) { this.transitionTo(TicketStatus.Open); this.resolution = undefined; } else if (this.status === TicketStatus.InProgress) { this.transitionTo(TicketStatus.Open); this.assigneeId = undefined; } else { throw new InvalidTransitionError(this.status, TicketStatus.Open); } } // Central transition logic private transitionTo(newStatus: TicketStatus): void { const allowedTransitions = VALID_TRANSITIONS[this.status]; if (!allowedTransitions.includes(newStatus)) { throw new InvalidTransitionError(this.status, newStatus); } this.status = newStatus; } // Query current state capabilities canResolve(): boolean { return this.status === TicketStatus.InProgress; } canClose(): boolean { return VALID_TRANSITIONS[this.status].includes(TicketStatus.Closed); } isClosed(): boolean { return this.status === TicketStatus.Closed; }}State-Dependent Behaviors:
Some behaviors are only valid in certain states. Others behave differently based on state. Your entity should enforce these rules:
canDoX() methods for UIs to enable/disable actionsFor entities with complex state, maintain a diagram showing all states and transitions. This is invaluable for onboarding, debugging, and ensuring business logic completeness. Tools like Mermaid or PlantUML can generate these from simple text descriptions.
A well-designed entity has cohesive behaviors—all behaviors relate to the entity's core purpose. When behaviors start spanning unrelated concerns, it's a sign your entity is doing too much.
Signs of poor cohesion:
How to improve cohesion:
A cohesive entity is easier to understand, test, and modify because all its parts serve a single purpose.
Sometimes accessing behavior through the main entity is convenient, like user.sendWelcomeEmail(). But convenience shouldn't override good design. Navigation like user.emailService.sendWelcome() or emailService.sendWelcome(user) maintains separation while remaining usable.
Let's examine common pitfalls in behavior design and how to avoid them:
setStatus(), setAmount(), etc. that let callers bypass business rules. Behaviors should encapsulate state changes with validation.process(shouldValidate: boolean). If behavior sometimes validates and sometimes doesn't, you have two different behaviors pretending to be one.calculateTotal() that also logs to database. getUser() that updates lastAccessTime. Be explicit about effects.getListOfItems()) instead of domain concepts.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// ❌ ANTI-PATTERNS class BadOrder { public status: string; // Anyone can set to anything! public items: Item[]; // External code can break invariants // Silent failure addItem(item: Item): boolean { if (this.status !== 'draft') { return false; // Caller might not check! } this.items.push(item); return true; } // Hidden side effect getTotal(): number { this.lastAccessTime = new Date(); // SURPRISE! return this.calculateTotal(); } // Flag argument complete(skipValidation: boolean = false): void { if (!skipValidation) { this.validate(); // Or not... scary } this.status = 'complete'; }} // ✅ CORRECT PATTERNS class GoodOrder { private status: OrderStatus; private readonly items: OrderItem[] = []; // Explicit failure addItem(item: OrderItem): void { if (this.status !== OrderStatus.Draft) { throw new CannotModifyNonDraftOrderError(this.id); } if (!item) { throw new ValidationError("Item is required"); } this.items.push(item); } // Pure query, no side effects calculateTotal(): Money { return this.items.reduce( (sum, item) => sum.add(item.subtotal), Money.zero() ); } // Separate behaviors, not flags complete(): void { this.validate(); // Always validates this.status = OrderStatus.Complete; } // If you truly need "force" version, make it explicit forceComplete(): void { // Clearly named, clearly dangerous // Might be admin-only, logged, etc. this.status = OrderStatus.Complete; }}We've explored behaviors as the actions that bring entities to life. Let's consolidate the key takeaways:
What's Next:
We've now covered the three fundamental aspects of entity modeling: identity (entities), description (attributes), and action (behaviors). The final page of this module brings it all together with Distinguishing Essential from Incidental Properties—a deep dive into the art of modeling entities that are complete without being bloated, focused without being anemic.
You now understand how to design behaviors that encapsulate business rules, enforce invariants, and model state transitions. Your entities can be active participants in your domain, not passive data containers. Next, we'll synthesize these concepts into a cohesive approach to entity design.