Loading learning content...
In Domain-Driven Design, we often focus on Entities—objects with distinct identities that we track throughout their lifecycle. A Customer with ID 42 remains the same customer even if they change their name or address. But there's another category of domain objects that's equally fundamental, yet frequently overlooked or misunderstood: Value Objects.
Value Objects represent the descriptive aspects of your domain. They don't have identity—they are defined entirely by their attributes. A Money object representing $100.00 USD doesn't have an identity; there's nothing distinguishing one "$100.00 USD" from another. What matters is what it describes, not which particular instance it is.
Understanding and properly implementing value objects is a hallmark of sophisticated domain modeling. They reduce bugs, improve expressiveness, and create self-documenting code that captures domain concepts with precision.
By the end of this page, you will understand what value objects are, how they differ from entities, and why they are essential building blocks in any well-designed domain model. You'll see concrete examples that illuminate when an object should be a value object and how to recognize them in your domain.
To understand value objects, we must first crystallize the concept of identity in domain modeling.
Identity means:
Consider a Customer entity:
123456789101112131415161718192021222324252627282930
// Customer is an ENTITY - identity mattersclass Customer { private readonly id: CustomerId; private name: string; private email: Email; private address: Address; constructor(id: CustomerId, name: string, email: Email, address: Address) { this.id = id; this.name = name; this.email = email; this.address = address; } // Customer 42 is STILL Customer 42 even after changing name changeName(newName: string): void { this.name = newName; } // Identity comparison - two customers are equal only if same ID equals(other: Customer): boolean { return this.id.equals(other.id); }} // Two different Customer objects with same attributes // are NOT the same customerconst customer1 = new Customer(new CustomerId(42), "Alice", email, address);const customer2 = new Customer(new CustomerId(99), "Alice", email, address);// customer1.equals(customer2) is FALSE - different identitiesNow consider: Does the address within that customer have identity? If the customer moves from "123 Main St" to "456 Oak Ave", is it the same address that transformed, or did the customer simply switch to a different address?
The answer is crucial: The address itself doesn't have identity. We don't track addresses across their "lifecycle." We care about what the address is, not which specific address instance it is. An address at "123 Main St, New York, NY 10001" is indistinguishable from any other address with those same values.
This is the essence of a Value Object.
A Value Object is an object that:
Eric Evans, in his seminal book Domain-Driven Design, describes value objects as objects that "describe some characteristic or attribute but carry no concept of identity."
Ask yourself: "If I have two instances with identical attributes, does it matter which one I use?" If the answer is NO—if they're completely interchangeable—you have a value object. If the answer is YES—if you need to track which specific instance—you have an entity.
| Characteristic | Value Object | Entity |
|---|---|---|
| Identity | None — defined by attributes | Distinct identity, tracked over time |
| Equality | By value (all attributes) | By identity (ID only) |
| Mutability | Immutable | Typically mutable |
| Lifecycle | No lifecycle, replaceable | Has lifecycle, state transitions |
| Examples | Money, DateRange, Address, Email | Customer, Order, Account, Product |
| Persistence | Embedded in entity | Has own table/document |
Let's examine several canonical examples of value objects that appear across countless domains. Understanding these patterns will help you recognize value objects in your own domain.
Money is perhaps the most universally applicable value object. A $50.00 USD is completely interchangeable with any other $50.00 USD. We don't track individual instances of "fifty dollars"—we care only about the amount and currency.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Money - a canonical value objectclass Money { private readonly amount: number; private readonly currency: Currency; private constructor(amount: number, currency: Currency) { // Validation at construction if (amount < 0) { throw new Error("Amount cannot be negative"); } this.amount = Money.roundToMinorUnits(amount, currency); this.currency = currency; } // Factory methods for creation static of(amount: number, currency: Currency): Money { return new Money(amount, currency); } static usd(amount: number): Money { return new Money(amount, Currency.USD); } static zero(currency: Currency): Money { return new Money(0, currency); } // Operations return NEW instances (immutability) add(other: Money): Money { this.ensureSameCurrency(other); return new Money(this.amount + other.amount, this.currency); } subtract(other: Money): Money { this.ensureSameCurrency(other); const result = this.amount - other.amount; if (result < 0) { throw new Error("Insufficient amount"); } return new Money(result, this.currency); } multiply(factor: number): Money { return new Money(this.amount * factor, this.currency); } // Value equality - two Money objects are equal if same amount AND currency equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; } // Immutable - no setters, no mutation methods getAmount(): number { return this.amount; } getCurrency(): Currency { return this.currency; } private ensureSameCurrency(other: Money): void { if (this.currency !== other.currency) { throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`); } } private static roundToMinorUnits(amount: number, currency: Currency): number { const minorUnits = currency.getMinorUnits(); const factor = Math.pow(10, minorUnits); return Math.round(amount * factor) / factor; }} // Usageconst price = Money.usd(99.99);const tax = Money.usd(8.00);const total = price.add(tax); // Returns NEW Money instance // These are equal - same valueconst fifty1 = Money.usd(50.00);const fifty2 = Money.usd(50.00);console.log(fifty1.equals(fifty2)); // trueA date range defines a period between two dates. The specific instance doesn't matter—what matters is the start and end dates.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// DateRange - a value object for temporal spansclass DateRange { private readonly start: Date; private readonly end: Date; private constructor(start: Date, end: Date) { if (start > end) { throw new Error("Start date must be before or equal to end date"); } this.start = new Date(start); // Defensive copy this.end = new Date(end); } static between(start: Date, end: Date): DateRange { return new DateRange(start, end); } static singleDay(date: Date): DateRange { return new DateRange(date, date); } // Query methods contains(date: Date): boolean { return date >= this.start && date <= this.end; } overlaps(other: DateRange): boolean { return this.start <= other.end && this.end >= other.start; } getDurationInDays(): number { const diffTime = this.end.getTime() - this.start.getTime(); return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; } // Operations return new instances extend(days: number): DateRange { const newEnd = new Date(this.end); newEnd.setDate(newEnd.getDate() + days); return new DateRange(this.start, newEnd); } intersect(other: DateRange): DateRange | null { if (!this.overlaps(other)) { return null; } const newStart = this.start > other.start ? this.start : other.start; const newEnd = this.end < other.end ? this.end : other.end; return new DateRange(newStart, newEnd); } // Value equality equals(other: DateRange): boolean { return this.start.getTime() === other.start.getTime() && this.end.getTime() === other.end.getTime(); } // Immutable accessors (return copies to prevent mutation) getStart(): Date { return new Date(this.start); } getEnd(): Date { return new Date(this.end); }}Addresses are composed of multiple parts (street, city, postal code), but the address as a whole has no identity. It describes a location.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
// Address - a composite value objectclass Address { private readonly street: string; private readonly city: string; private readonly state: string; private readonly postalCode: PostalCode; private readonly country: Country; private constructor( street: string, city: string, state: string, postalCode: PostalCode, country: Country ) { // Validation if (!street.trim()) { throw new Error("Street is required"); } if (!city.trim()) { throw new Error("City is required"); } this.street = street.trim(); this.city = city.trim(); this.state = state.trim(); this.postalCode = postalCode; this.country = country; } static create( street: string, city: string, state: string, postalCode: string, country: string ): Address { return new Address( street, city, state, PostalCode.of(postalCode), Country.of(country) ); } // Returns new address with changed street // (used when customer provides correction, for example) withStreet(newStreet: string): Address { return new Address( newStreet, this.city, this.state, this.postalCode, this.country ); } isSameCity(other: Address): boolean { return this.city === other.city && this.country.equals(other.country); } formatForMailing(): string { return `${this.street}\n${this.city}, ${this.state} ${this.postalCode}\n${this.country}`; } // Value equality - all fields must match equals(other: Address): boolean { return this.street === other.street && this.city === other.city && this.state === other.state && this.postalCode.equals(other.postalCode) && this.country.equals(other.country); }} // PostalCode is ALSO a value objectclass PostalCode { private readonly value: string; private constructor(value: string) { if (!PostalCode.isValid(value)) { throw new Error(`Invalid postal code: ${value}`); } this.value = value.toUpperCase().replace(/\s/g, ''); } static of(value: string): PostalCode { return new PostalCode(value); } private static isValid(value: string): boolean { // Simple validation - adjust for your country const normalized = value.replace(/\s/g, ''); return /^[A-Z0-9]{3,10}$/.test(normalized.toUpperCase()); } equals(other: PostalCode): boolean { return this.value === other.value; } toString(): string { return this.value; }}One of the most common code smells addressed by value objects is Primitive Obsession—the overuse of primitive types (strings, numbers, booleans) to represent domain concepts.
Consider an email address. In many codebases, you'll see:
123456789101112131415161718192021222324252627282930
// ❌ PRIMITIVE OBSESSION: Email as stringclass Customer { private email: string; // Just a string! setEmail(email: string): void { // Validation scattered everywhere email is used if (!email.includes('@')) { throw new Error('Invalid email'); } this.email = email; }} // Same validation repeated in different classesclass Newsletter { subscribe(email: string): void { if (!email.includes('@')) { throw new Error('Invalid email'); } // ... subscribe logic }} // Bug: someone forgot to validateclass NotificationService { send(email: string, message: string): void { // Oops, no validation here! this.emailClient.send(email, message); }}When domain concepts are represented as primitives: (1) Validation is scattered and duplicated, (2) Invalid values can slip through cracks, (3) Domain logic has no natural home, (4) Code loses domain expressiveness, (5) Mixing incompatible values (email vs phone number) causes silent bugs.
The solution is to create a proper value object:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// ✅ PROPER VALUE OBJECT: Email with behaviorclass Email { private readonly value: string; private constructor(value: string) { const normalized = value.toLowerCase().trim(); if (!Email.isValid(normalized)) { throw new InvalidEmailError(value); } this.value = normalized; } static of(value: string): Email { return new Email(value); } private static isValid(email: string): boolean { // Comprehensive validation in ONE place const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return emailRegex.test(email) && email.length <= 254; } getDomain(): string { return this.value.split('@')[1]; } getLocalPart(): string { return this.value.split('@')[0]; } isBusinessEmail(): boolean { const personalDomains = ['gmail.com', 'yahoo.com', 'hotmail.com']; return !personalDomains.includes(this.getDomain()); } equals(other: Email): boolean { return this.value === other.value; } toString(): string { return this.value; }} // Now the Customer class is cleaner and saferclass Customer { private email: Email; // Type system enforces validity setEmail(email: Email): void { this.email = email; // Already validated at creation }} // Newsletter can't receive invalid emailsclass Newsletter { subscribe(email: Email): void { // email is GUARANTEED to be valid if (email.isBusinessEmail()) { this.addToCorporateList(email); } else { this.addToPersonalList(email); } }} // Type system prevents mixing incompatible valuesfunction processCustomer( email: Email, // Must pass Email object phone: PhoneNumber // Must pass PhoneNumber object): void { // Can't accidentally pass phone where email is expected}Email object, it's valid.email.getDomain(), email.isBusinessEmail() belong here.send(Email email) vs send(String email).A well-designed value object is:
equals() and hashCode() properly.Immutability is the cornerstone of value objects. A mutable value object is a contradiction—if it can change, it has state that persists across changes, which implies tracking identity. Immutability also provides thread-safety for free: you can share value objects across threads without synchronization.
Value equality must be implemented correctly. Two Money objects of $50.00 USD must be equal regardless of which instance they are:
1234567891011121314151617181920212223242526272829303132333435
class Money { private readonly amount: number; private readonly currency: Currency; // ... constructor and other methods ... equals(other: Money | null | undefined): boolean { if (other === null || other === undefined) { return false; } if (!(other instanceof Money)) { return false; } return this.amount === other.amount && this.currency === other.currency; } // In TypeScript/JavaScript, also override for collections // that might use string keys hashCode(): number { let hash = 17; hash = hash * 31 + Math.floor(this.amount * 100); hash = hash * 31 + this.currency.hashCode(); return hash; }} // Usage demonstrating value equalityconst m1 = Money.usd(100);const m2 = Money.usd(100);const m3 = Money.usd(50); console.log(m1.equals(m2)); // true - same valueconsole.log(m1.equals(m3)); // false - different amountsconsole.log(m1 === m2); // false - different references (expected!)While value objects are domain-specific, certain patterns appear across most business domains. Recognizing these helps you identify candidates in your own domain:
| Category | Value Objects | Key Behaviors |
|---|---|---|
| Monetary | Money, Currency, Tax, Discount, Price | Arithmetic, formatting, currency conversion |
| Temporal | DateRange, TimeSlot, Duration, Deadline | Overlap detection, containment, comparison |
| Geographic | Address, Coordinates, Distance, Region | Formatting, proximity, containment |
| Identity Tokens | Email, PhoneNumber, URL, SSN, VIN | Validation, normalization, parsing |
| Quantities | Weight, Volume, Length, Temperature | Unit conversion, arithmetic, comparison |
| Business | SKU, OrderNumber, InvoiceNumber | Validation, formatting, parsing |
| Ranges | PriceRange, AgeRange, QuantityRange | Containment, overlap, intersection |
| Composite | FullName, PersonName, CompanyName | Formatting, parsing, comparison |
Look for attributes in your entities that are primitives but carry domain meaning. Ask: 'What validation should this have? What operations make sense on this value?' If there are behaviors beyond simple get/set, you likely have a value object hiding inside a primitive.
Different programming languages offer varying levels of support for value object implementation. Some languages have built-in constructs; others require more ceremony.
Java 16+ introduced records, which are ideal for simple value objects:
12345678910111213141516171819202122232425262728
// Java Record - immutable value object with minimal boilerplatepublic record Money(BigDecimal amount, Currency currency) { // Compact constructor for validation public Money { if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("Amount cannot be negative"); } amount = amount.setScale(2, RoundingMode.HALF_UP); } // Additional factory methods public static Money usd(double amount) { return new Money(BigDecimal.valueOf(amount), Currency.USD); } // Operations return new instances public Money add(Money other) { requireSameCurrency(other); return new Money(amount.add(other.amount), currency); }} // Records provide:// - equals() based on all components// - hashCode() based on all components // - toString() showing all components// - Immutability (fields are final)We've established the foundational understanding of value objects. Let's consolidate the key insights:
What's next:
Now that we understand what value objects are, we'll dive deep into their two most critical characteristics: immutability and equality. The next page explores why immutability is non-negotiable, how to implement proper equality, and the subtle bugs that arise when these are done incorrectly.
You now understand the fundamental nature of value objects—identity-less, immutable objects defined by their attributes. You can recognize when a domain concept should be a value object and understand how they differ from entities. Next, we'll explore the critical topics of immutability and equality in depth.