Value Object
Concept
A Value Object is a domain object defined entirely by its attributes. It has no identity — two Value Objects with identical attributes are considered the same and are fully interchangeable. Value Objects are immutable: instead of modifying one, you create a new instance.
Value Objects capture domain concepts that are defined by what they are, not who they are: Money is defined by its amount and currency; an Email by its address string; an Address by street, city, and postal code.
Key Characteristics
- No identity — no ID field, no repository, no lifecycle
- Immutable — never modify in place; return a new instance from every operation
- Value-based equality — two instances with the same attributes are equal
- Self-validating — throw in the constructor if invariants are violated
- Side-effect-free behavior — operations return new Value Objects, never mutate
- Conceptual whole — groups related attributes that only make sense together (amount + currency, not just a number)
When to Use
Use Value Object when:
- The concept is fully described by its values (Money, Email, Address, PhoneNumber, DateRange)
- Two instances with the same values should be treated as the same thing
- The concept enforces its own invariants (valid email format, non-negative amount)
- The concept has domain operations that produce new values (
money.add(other))
Don’t use Value Object when:
- The concept has a lifecycle or needs to be tracked independently (use Entity)
- The concept is just a DTO passed between layers with no domain behavior
Core Patterns
Money — arithmetic and currency safety
// domain/value-objects/Money.ts
export class Money {
constructor(
readonly amount: number,
readonly currency: string,
) {
if (amount < 0) throw new Error('Amount cannot be negative');
if (!currency) throw new Error('Currency is required');
}
add(other: Money): Money {
this.assertSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.assertSameCurrency(other);
if (other.amount > this.amount) throw new Error('Insufficient funds');
return new Money(this.amount - other.amount, this.currency);
}
multiply(factor: number): Money {
if (factor < 0) throw new Error('Factor cannot be negative');
return new Money(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
private assertSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error(`Currency mismatch: ${this.currency} vs ${other.currency}`);
}
}
}
// Usage — immutability: operations always return new instances
const price = new Money(100, 'USD');
const tax = new Money(8.5, 'USD');
const total = price.add(tax); // new Money(108.5, 'USD')
const doubled = price.multiply(2); // new Money(200, 'USD')
console.log(price.equals(new Money(100, 'USD'))); // true
console.log(price === new Money(100, 'USD')); // false — different instances
Email — normalization and validation
// domain/value-objects/Email.ts
export class Email {
private readonly _value: string;
constructor(email: string) {
const normalized = email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
throw new Error(`Invalid email: ${email}`);
}
this._value = normalized;
}
toString(): string { return this._value; }
equals(other: Email): boolean { return this._value === other._value; }
}
// Usage
const email = new Email('USER@EXAMPLE.COM');
console.log(email.toString()); // 'user@example.com' — normalized
DateRange — composite value with domain operations
// domain/value-objects/DateRange.ts
export class DateRange {
constructor(
readonly start: Date,
readonly end: Date,
) {
if (end <= start) throw new Error('End must be after start');
}
includes(date: Date): boolean {
return date >= this.start && date <= this.end;
}
overlaps(other: DateRange): boolean {
return this.start < other.end && this.end > other.start;
}
durationDays(): number {
return Math.ceil((this.end.getTime() - this.start.getTime()) / 86_400_000);
}
equals(other: DateRange): boolean {
return this.start.getTime() === other.start.getTime()
&& this.end.getTime() === other.end.getTime();
}
}
Common Mistakes
❌ Using primitives instead of Value Objects:
// WRONG: primitive types allow invalid states and no domain behavior
function applyDiscount(price: number, currency: string, discount: number): number {
return price * (1 - discount); // currency mismatch possible, no validation
}
// CORRECT: Value Object enforces rules and carries domain operations
const discounted = price.multiply(1 - discount); // currency stays, rules apply
❌ Making Value Objects mutable:
// WRONG: mutating in place breaks immutability contract
class Money {
amount: number;
addAmount(n: number): void { this.amount += n; } // mutates!
}
// CORRECT: return new instance
class Money {
add(other: Money): Money { return new Money(this.amount + other.amount, this.currency); }
}
❌ Incomplete equality:
// WRONG: missing currency comparison
equals(other: Money): boolean { return this.amount === other.amount; }
// 100 USD === 100 EUR → true! Wrong.
// CORRECT: all attributes must be compared
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}