Loading learning content...
One of the most consequential decisions in domain modeling is determining which concepts should be value objects and which should be entities. Get this decision right, and your domain model becomes clean, expressive, and maintainable. Get it wrong, and you'll fight against your model at every turn.
Many developers default to entities for everything—every table gets a class with an ID. This leads to bloated models where simple concepts carry unnecessary identity baggage. On the other hand, sometimes concepts that seem like simple values actually need identity tracking.
This page provides practical guidelines for making this decision. You'll learn to recognize value object opportunities hiding in plain sight and avoid the common pitfalls that lead to poor domain models.
By the end of this page, you will have a toolkit of decision heuristics for identifying value objects. You'll understand the common mistakes developers make, see real-world refactoring examples, and know how to spot hidden value objects in existing codebases.
The fundamental question is: Does this concept have identity that matters beyond its attributes?
This question can be asked in several equivalent ways:
Let's apply these tests to concrete examples:
Money ($100.00 USD)
Conclusion: Value Object ✓
Customer (John Smith)
Conclusion: Entity ✓
Discuss with domain experts. Ask: 'If I have two [things] with the same [attributes], are they the same or different?' Domain experts usually have clear intuitions about identity that reveal the correct modeling choice.
Beyond the identity question, several heuristics help identify value object candidates in your domain:
Concepts that describe, measure, or quantify something are almost always value objects:
| Category | Examples |
|---|---|
| Monetary | Money, Price, Cost, Tax, Discount, Balance |
| Temporal | Duration, DateRange, TimeSlot, Deadline, Schedule |
| Geographic | Address, Coordinates, Distance, Region, Country |
| Physical | Weight, Volume, Length, Temperature, Speed |
| Business Metrics | Percentage, Rating, Score, Quantity |
Strings and numbers representing domain concepts should usually become value objects:
12345678910111213141516171819202122232425262728293031323334353637
// ❌ BEFORE: Primitives representing domain conceptsclass Customer { private email: string; // Should be Email private phoneNumber: string; // Should be PhoneNumber private socialSecurityNumber: string; // Should be SSN} class Order { private totalAmount: number; // Should be Money private discountPercent: number; // Should be Percentage private orderNumber: string; // Should be OrderNumber} class Product { private sku: string; // Should be SKU private weight: number; // Should be Weight private price: number; // Should be Money} // ✅ AFTER: Rich value objectsclass Customer { private email: Email; private phoneNumber: PhoneNumber; private ssn: SSN;} class Order { private total: Money; private discount: Percentage; private orderNumber: OrderNumber;} class Product { private sku: SKU; private weight: Weight; private price: Money;}If you find yourself repeatedly validating or formatting a primitive value, it's a hidden value object:
1234567891011121314151617181920212223242526272829303132
// ❌ Scattered validation = hidden value objectfunction createUser(email: string) { if (!isValidEmail(email)) throw new Error("Invalid email"); // ...} function updateEmail(userId: string, email: string) { if (!isValidEmail(email)) throw new Error("Invalid email"); // ...} function sendNotification(email: string) { if (!isValidEmail(email)) throw new Error("Invalid email"); // ...} // Formatting in multiple placesfunction displayEmail(email: string): string { return email.toLowerCase().trim();} function storeEmail(email: string): string { return email.toLowerCase().trim();} // ✅ Proper value object centralizes thisclass Email { static of(value: string): Email { // Single location for validation // Single location for normalization }}If multiple attributes always change together and represent a coherent concept, they should be grouped into a value object:
1234567891011121314151617181920212223242526272829303132333435363738
// ❌ BEFORE: Related attributes spread across entityclass Customer { private streetAddress: string; private city: string; private state: string; private postalCode: string; private country: string; // These always change together updateAddress( street: string, city: string, state: string, postalCode: string, country: string ): void { this.streetAddress = street; this.city = city; this.state = state; this.postalCode = postalCode; this.country = country; }} // ✅ AFTER: Cohesive value objectclass Customer { private address: Address; updateAddress(address: Address): void { this.address = address; }} class Address { // All address components grouped // Validation at construction // Address-specific behavior}If you find operations scattered in utilities or services that operate on primitives, that's a value object trying to emerge:
123456789101112131415161718192021222324252627
// ❌ Operations scattered in utilitiesclass DateRangeUtils { static overlaps(start1: Date, end1: Date, start2: Date, end2: Date): boolean { } static contains(start: Date, end: Date, date: Date): boolean { } static getDuration(start: Date, end: Date): number { } static merge(start1: Date, end1: Date, start2: Date, end2: Date): [Date, Date] { }} class MoneyUtils { static add(amount1: number, currency1: string, amount2: number, currency2: string): number { } static format(amount: number, currency: string): string { } static convert(amount: number, from: string, to: string): number { }} // ✅ Operations belong on value objectsclass DateRange { overlaps(other: DateRange): boolean { } contains(date: Date): boolean { } getDuration(): Duration { } merge(other: DateRange): DateRange { }} class Money { add(other: Money): Money { } format(): string { } convertTo(currency: Currency): Money { }}When you're unsure, walk through this decision tree:
Let's apply this decision tree to some examples:
Example 1: Product Review
Example 2: Star Rating (1-5 stars)
Example 3: Shipping Address on an Order
The same real-world concept might be an Entity in one context and a Value Object in another. An Address in an address book system (where users manage their saved addresses) might be an Entity. The same Address captured on a completed Order is a Value Object—a snapshot of the address at order time.
Even experienced developers make predictable errors when deciding between entities and value objects. Here are the most common mistakes:
The most common mistake is making everything an Entity because "it has a database table" or "we might need to track it later."
Just because something is stored in a database with a primary key doesn't mean it's an Entity in your domain model:
1234567891011121314151617181920212223242526
// ❌ MISTAKE: Database structure driving domain model// Database has a Money table with id, amount, currency columns// Developer creates:class MoneyEntity { private id: number; // From database private amount: number; private currency: string; setAmount(amount: number): void { this.amount = amount; // Mutable! }} // ✅ CORRECT: Domain model independent of persistenceclass Money { // Value Object private readonly amount: number; private readonly currency: Currency; // No ID - stored as embedded fields in parent entity's table // Or stored in lookup table but still modeled as Value Object} // Persistence decisions are separate from modeling decisions// Money can be stored in:// - Embedded columns (order.total_amount, order.total_currency)// - Separate table (for sharing/caching) with technical ID// Either way, it's a VALUE OBJECT in the domain modelDevelopers sometimes resist value objects because "the business says this can change" — but changes should create new value objects, not mutate existing ones:
123456789101112131415161718192021
// ❌ MISTAKE: "Price changes, so it can't be a value object"class Product { private price: MutablePrice; updatePrice(newAmount: number): void { this.price.setAmount(newAmount); // Mutating the price }} // ✅ CORRECT: Change means replacement, not mutationclass Product { private price: Money; // Immutable value object updatePrice(newPrice: Money): void { this.price = newPrice; // Replace with new value object this.recordPriceChange(this.price, newPrice); }} // The PRODUCT is the entity with changing state// PRICE is just a value that can be replacedValue objects often hide in plain sight as multiple primitive fields that belong together:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// ❌ Hidden value objects as primitive fieldsclass Employee { private salaryAmount: number; private salaryCurrency: string; private salaryFrequency: string; // "MONTHLY", "ANNUAL" private managerFirstName: string; private managerLastName: string; private managerEmail: string; private officeBuilding: string; private officeFloor: number; private officeDesk: string;} // ✅ Extract the hidden value objectsclass Employee { private salary: Salary; // Value Object private manager: ManagerInfo; // Value Object private officeLocation: OfficeLocation; // Value Object} class Salary { constructor( private readonly amount: Money, private readonly frequency: PayFrequency ) {} annualized(): Money { } monthly(): Money { }} class ManagerInfo { constructor( private readonly name: PersonName, private readonly email: Email ) {}} class OfficeLocation { constructor( private readonly building: Building, private readonly floor: number, private readonly desk: string ) {} format(): string { }}Sometimes concepts look like value objects but actually require entity semantics. Here are scenarios where you should use an Entity instead:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// EXAMPLE: Document seems like it could be a value object// (it's just content), but... // ❌ Document as Value Object - problematicclass DocumentVO { constructor( private readonly content: string, // Could be 10MB private readonly metadata: Metadata ) {} // Every 'edit' creates a full copy - expensive! addParagraph(text: string): DocumentVO { return new DocumentVO( this.content + "\n" + text, // 10MB copy! this.metadata.withModified(new Date()) ); }} // ✅ Document as Entity - correctclass Document { private id: DocumentId; private content: string; private version: number; private modifiedAt: Date; // Mutation is tracked, versioned addParagraph(text: string): void { this.content += "\n" + text; this.version++; this.modifiedAt = new Date(); this.publishEvent(new DocumentModified(this.id, this.version)); }} // EXAMPLE: Tag seems simple but needs Entity treatment // This seems like a value object...class TagVO { constructor(private readonly name: string) {}} // But if multiple entities "share" a tag and we want to:// - Track which entities use each tag// - Rename a tag and have all uses updated// - Show usage counts for each tag// Then Tag needs to be an Entity: class Tag { private id: TagId; private name: string; private usageCount: number; rename(newName: string): void { const oldName = this.name; this.name = newName; this.publishEvent(new TagRenamed(this.id, oldName, newName)); }}It's easier to promote a Value Object to an Entity (add identity) than to demote an Entity to a Value Object (remove identity and make immutable). When uncertain, start with Value Object and evolve if needed.
Value objects appear at multiple levels of abstraction in your domain model, from simple wrappers to complex composite structures:
| Level | Purpose | Examples |
|---|---|---|
| Tiny Types | Eliminate primitive obsession for single values | Email, CustomerId, OrderNumber, SSN |
| Measures | Capture quantity + unit combinations | Money, Weight, Duration, Temperature |
| Ranges | Define bounded intervals | DateRange, PriceRange, AgeRange |
| Structures | Group related attributes | Address, GeoCoordinates, PersonName |
| Computations | Encapsulate calculation results | PriceBreakdown, TaxCalculation, ShippingEstimate |
| Policies | Embed business rules as objects | PricingStrategy, DiscountPolicy, ShippingRules |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
// Level 1: Tiny Type - eliminates primitive obsessionclass CustomerId { private constructor(private readonly value: string) {} static of(value: string): CustomerId { if (!/^CUS-[A-Z0-9]{8}$/.test(value)) { throw new InvalidCustomerIdError(value); } return new CustomerId(value); }} // Level 2: Measure - quantity + unitclass Weight { private constructor( private readonly value: number, private readonly unit: WeightUnit ) {} static kilograms(kg: number): Weight { return new Weight(kg, WeightUnit.KG); } static pounds(lb: number): Weight { return new Weight(lb, WeightUnit.LB); } toKilograms(): number { } toPounds(): number { } add(other: Weight): Weight { }} // Level 3: Range - bounded intervalclass PriceRange { private constructor( private readonly min: Money, private readonly max: Money ) {} contains(price: Money): boolean { } overlaps(other: PriceRange): boolean { }} // Level 4: Structure - grouped attributesclass PersonName { private constructor( private readonly firstName: string, private readonly lastName: string, private readonly middleName: string | null ) {} formatFormal(): string { } // "Smith, John M." formatFriendly(): string { } // "John" formatFull(): string { } // "John Michael Smith"} // Level 5: Computation Result - captures complex calculationclass ShippingEstimate { private constructor( private readonly baseCost: Money, private readonly fuelSurcharge: Money, private readonly insuranceFee: Money, private readonly estimatedDays: number, private readonly carrier: Carrier ) {} getTotalCost(): Money { } getDeliveryDate(fromDate: Date): Date { }} // Level 6: Policy as Value Object - embeds business rulesclass DiscountPolicy { private constructor( private readonly tiers: readonly DiscountTier[] ) {} static tiered(...tiers: DiscountTier[]): DiscountPolicy { return new DiscountPolicy(tiers); } calculateDiscount(orderTotal: Money): Money { const applicableTier = this.tiers .filter(t => orderTotal.isGreaterOrEqual(t.threshold)) .sort((a, b) => b.threshold.compare(a.threshold))[0]; return applicableTier ? applicableTier.discountPercent.apply(orderTotal) : Money.zero(orderTotal.getCurrency()); }} const loyaltyDiscount = DiscountPolicy.tiered( DiscountTier.of(Money.usd(100), Percentage.of(5)), DiscountTier.of(Money.usd(500), Percentage.of(10)), DiscountTier.of(Money.usd(1000), Percentage.of(15)));When you identify hidden value objects in existing code, here's a systematic approach to extract them:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Step 1: Identify - scattered email handling in Order entityclass Order_Before { private customerEmail: string; setCustomerEmail(email: string): void { // Validation here if (!email.includes('@') || email.length > 254) { throw new Error("Invalid email"); } this.customerEmail = email.toLowerCase().trim(); } sendConfirmation(): void { // Validation repeated! if (!this.customerEmail.includes('@')) { throw new Error("Invalid email"); } this.emailService.send(this.customerEmail, ...); } formatReceipt(): string { return `Order for: ${this.customerEmail.toLowerCase()}`; }} // Steps 2-5: Create value object with validation and behaviorclass Email { private readonly value: string; private constructor(value: string) { this.value = value; } static of(value: string): Email { // Consolidated validation const normalized = value.toLowerCase().trim(); if (!normalized.includes('@') || normalized.length > 254) { throw new InvalidEmailError(value); } return new Email(normalized); } // Behavior moved here getDomain(): string { return this.value.split('@')[1]; } toString(): string { return this.value; } equals(other: Email): boolean { return this.value === other.value; }} // Step 6: Replace primitive in entityclass Order_After { private customerEmail: Email; // Now a value object setCustomerEmail(email: Email): void { // No validation needed - Email is always valid this.customerEmail = email; } sendConfirmation(): void { // No validation needed this.emailService.send(this.customerEmail, ...); } formatReceipt(): string { // Natural toString return `Order for: ${this.customerEmail}`; }}We've covered comprehensive guidelines for deciding when to use value objects. Let's consolidate the key insights:
Congratulations! You've completed the Value Objects module. You now understand what value objects are, how to implement them with proper immutability and equality, how to design expressive and composable value objects, and when to use them in your domain models. This knowledge forms a crucial foundation for building rich, maintainable domain models in Domain-Driven Design.
What's next:
With Entities and Value Objects mastered, the next module covers Aggregates—consistency boundaries that group entities and value objects together into cohesive units with clear invariants and transactional boundaries.