Loading learning content...
Understanding what value objects are is only the beginning. The real skill lies in designing value objects that elegantly capture domain concepts, validate invariants, and expose behavior that makes code expressive and self-documenting.
A well-designed value object is more than just an immutable wrapper around primitives. It's a rich domain concept that:
In this page, we'll explore the principles and patterns that separate mediocre value objects from excellent ones—the kind that make code a pleasure to work with.
By the end of this page, you will know how to design value objects with proper factory methods, self-validation, rich behavior, and smart composition. You'll learn patterns for handling optional components, collections, and derived values, creating value objects that truly model your domain.
While constructors can work for simple value objects, factory methods offer significant advantages for expressive, maintainable designs. They provide named creation points that reveal intent and handle complex creation logic cleanly.
Money.usd(100) is clearer than new Money(100, Currency.USD).12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
class Money { private readonly amountInCents: number; private readonly currency: Currency; // Private constructor - all creation via factory methods private constructor(amountInCents: number, currency: Currency) { this.amountInCents = amountInCents; this.currency = currency; } // Named factory method for common case static usd(dollars: number): Money { return Money.of(dollars, Currency.USD); } static eur(euros: number): Money { return Money.of(euros, Currency.EUR); } // General factory method static of(amount: number, currency: Currency): Money { if (amount < 0) { throw new NegativeAmountError(amount); } const cents = Math.round(amount * Math.pow(10, currency.minorUnits)); return new Money(cents, currency); } // Create from minor units (cents) static ofMinor(minorUnits: number, currency: Currency): Money { if (minorUnits < 0) { throw new NegativeAmountError(minorUnits); } if (!Number.isInteger(minorUnits)) { throw new Error("Minor units must be an integer"); } return new Money(minorUnits, currency); } // Zero amount factory - common need static zero(currency: Currency): Money { return new Money(0, currency); } // Parsing from string static parse(input: string): Money { // "$100.50 USD" -> Money(10050, USD) const match = input.match(/^([\$€£¥])?(\d+(?:\.\d{2})?)s*(\w{3})?$/); if (!match) { throw new InvalidMoneyFormatError(input); } const [, symbol, amountStr, currencyCode] = match; const currency = Currency.fromSymbolOrCode(symbol, currencyCode); return Money.of(parseFloat(amountStr), currency); } // Result-returning factory for controlled error handling static tryParse(input: string): Result<Money, ParseError> { try { return Result.success(Money.parse(input)); } catch (e) { return Result.failure(new ParseError(input, e.message)); } } // Getters getAmount(): number { return this.amountInCents / Math.pow(10, this.currency.minorUnits); } getAmountInMinorUnits(): number { return this.amountInCents; } getCurrency(): Currency { return this.currency; }} // Usage - intent is immediately clear from method namesconst price = Money.usd(99.99);const shipping = Money.eur(5.00);const noCharge = Money.zero(Currency.USD);const parsed = Money.parse("$149.99 USD");const fromApi = Money.ofMinor(9999, Currency.USD); // 99.99 from centsOne of the most powerful aspects of value objects is self-validation. An invalid value object should not be able to exist. This principle—known as "make illegal states unrepresentable"—shifts validation from scattered checks throughout your codebase to a single, authoritative location: the value object itself.
If you have a valid instance of a value object, it is guaranteed to be valid. Period. No additional validation needed. This eliminates defensive programming throughout your codebase and prevents invalid data from propagating through your system.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
// Email address with comprehensive validationclass Email { private readonly value: string; private constructor(value: string) { this.value = value; } static of(value: string): Email { const normalized = Email.normalize(value); Email.validate(normalized); return new Email(normalized); } private static normalize(value: string): string { return value.toLowerCase().trim(); } private static validate(email: string): void { // Check for presence if (!email) { throw new EmptyEmailError(); } // Check length (RFC 5321) if (email.length > 254) { throw new EmailTooLongError(email); } // Check format const parts = email.split('@'); if (parts.length !== 2) { throw new InvalidEmailFormatError(email, "Must contain exactly one @"); } const [localPart, domain] = parts; // Validate local part if (localPart.length === 0 || localPart.length > 64) { throw new InvalidEmailFormatError(email, "Invalid local part length"); } // Validate domain if (!domain.includes('.')) { throw new InvalidEmailFormatError(email, "Domain must contain a dot"); } // Additional checks as needed... } // Rich behavior getDomain(): string { return this.value.split('@')[1]; } getLocalPart(): string { return this.value.split('@')[0]; } isBusinessEmail(): boolean { const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com']; return !freeProviders.includes(this.getDomain()); } matches(pattern: RegExp): boolean { return pattern.test(this.value); } toString(): string { return this.value; }} // Phone number with country-specific validationclass PhoneNumber { private readonly countryCode: string; private readonly nationalNumber: string; private constructor(countryCode: string, nationalNumber: string) { this.countryCode = countryCode; this.nationalNumber = nationalNumber; } static of(countryCode: string, nationalNumber: string): PhoneNumber { PhoneNumber.validate(countryCode, nationalNumber); return new PhoneNumber( countryCode, PhoneNumber.normalize(nationalNumber) ); } static parse(fullNumber: string): PhoneNumber { // "+1-555-123-4567" -> PhoneNumber("1", "5551234567") const cleaned = fullNumber.replace(/[^0-9+]/g, ''); if (!cleaned.startsWith('+')) { throw new InvalidPhoneNumberError(fullNumber, "Must start with +"); } // Extract country code and national number // (simplified - real implementation is more complex) const countryCode = cleaned.substring(1, 2); const nationalNumber = cleaned.substring(2); return PhoneNumber.of(countryCode, nationalNumber); } private static validate(countryCode: string, nationalNumber: string): void { if (!/^[1-9]\d{0,2}$/.test(countryCode)) { throw new InvalidCountryCodeError(countryCode); } if (!/^\d{4,14}$/.test(nationalNumber)) { throw new InvalidNationalNumberError(nationalNumber); } } private static normalize(nationalNumber: string): string { return nationalNumber.replace(/\D/g, ''); } format(): string { return `+${this.countryCode} ${this.formatNational()}`; } private formatNational(): string { // Country-specific formatting if (this.countryCode === '1') { // US format: (555) 123-4567 const n = this.nationalNumber; return `(${n.slice(0,3)}) ${n.slice(3,6)}-${n.slice(6)}`; } return this.nationalNumber; }}Different validation scenarios call for different strategies:
| Strategy | When to Use | Example |
|---|---|---|
| Throw exception | Invalid input is a programmer error or exceptional condition | Negative money amount, malformed email |
| Return Result/Either | Invalid input is expected in normal flow (e.g., user input) | Form validation, API parsing |
| Return Optional/null | Parsing where failure just means 'no value' | Optional preference settings |
| Throw domain-specific exception | Domain rules violated, need specific handling | InsufficientFundsException, BlacklistedEmailException |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
// Result type for graceful error handlingtype Result<T, E> = | { success: true; value: T } | { success: false; error: E }; class Percentage { private readonly value: number; private constructor(value: number) { this.value = value; } // Throws for programmer errors static of(value: number): Percentage { if (value < 0 || value > 100) { throw new InvalidPercentageError(value); } return new Percentage(value); } // Returns Result for user input validation static tryParse(input: string): Result<Percentage, ValidationError> { const trimmed = input.trim().replace('%', ''); const parsed = parseFloat(trimmed); if (isNaN(parsed)) { return { success: false, error: new ValidationError(`'${input}' is not a valid number`) }; } if (parsed < 0 || parsed > 100) { return { success: false, error: new ValidationError(`Percentage must be between 0 and 100`) }; } return { success: true, value: new Percentage(parsed) }; }} // Usage with Resultconst result = Percentage.tryParse(userInput);if (result.success) { applyDiscount(result.value);} else { displayError(result.error.message);}Value objects should not be merely data containers. They should encapsulate behavior that naturally belongs to the concept they represent. This is often where developers miss the full potential of value objects.
For each value object, ask: 'What operations, queries, or transformations make sense for this concept?' Put that behavior on the value object, not scattered across services and utilities.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
class DateRange { private readonly start: Date; private readonly end: Date; private constructor(start: Date, end: Date) { this.start = start; this.end = end; } static between(start: Date, end: Date): DateRange { if (start > end) { throw new InvalidDateRangeError(start, end); } return new DateRange(new Date(start), new Date(end)); } // ======================================== // Query Methods - Answer questions about the range // ======================================== contains(date: Date): boolean { return date >= this.start && date <= this.end; } containsRange(other: DateRange): boolean { return this.start <= other.start && this.end >= other.end; } overlaps(other: DateRange): boolean { return this.start <= other.end && this.end >= other.start; } isAdjacent(other: DateRange): boolean { const nextDay = (d: Date) => new Date(d.getTime() + 86400000); return this.end.getTime() === other.start.getTime() - 86400000 || this.start.getTime() === other.end.getTime() + 86400000; } isEmpty(): boolean { return this.start.getTime() === this.end.getTime(); } // ======================================== // Calculation Methods - Derive information // ======================================== getDurationInDays(): number { const diffMs = this.end.getTime() - this.start.getTime(); return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; } getDurationInWeeks(): number { return Math.floor(this.getDurationInDays() / 7); } getWorkingDays(): number { let count = 0; const current = new Date(this.start); while (current <= this.end) { const dayOfWeek = current.getDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { count++; } current.setDate(current.getDate() + 1); } return count; } // ======================================== // Transformation Methods - Create new ranges // ======================================== extend(days: number): DateRange { const newEnd = new Date(this.end); newEnd.setDate(newEnd.getDate() + days); return new DateRange(this.start, newEnd); } shift(days: number): DateRange { const newStart = new Date(this.start); const newEnd = new Date(this.end); newStart.setDate(newStart.getDate() + days); newEnd.setDate(newEnd.getDate() + days); return new DateRange(newStart, 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); } gap(other: DateRange): DateRange | null { if (this.overlaps(other) || this.isAdjacent(other)) { return null; } if (this.end < other.start) { const gapStart = new Date(this.end); gapStart.setDate(gapStart.getDate() + 1); const gapEnd = new Date(other.start); gapEnd.setDate(gapEnd.getDate() - 1); return new DateRange(gapStart, gapEnd); } return other.gap(this); } merge(other: DateRange): DateRange { if (!this.overlaps(other) && !this.isAdjacent(other)) { throw new NonContiguousRangesError(this, other); } 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); } splitAt(date: Date): [DateRange, DateRange] { if (!this.contains(date)) { throw new DateNotInRangeError(date, this); } const nextDay = new Date(date); nextDay.setDate(nextDay.getDate() + 1); return [ new DateRange(this.start, date), new DateRange(nextDay, this.end) ]; } // ======================================== // Iteration Methods - Work with individual dates // ======================================== *days(): Generator<Date> { const current = new Date(this.start); while (current <= this.end) { yield new Date(current); current.setDate(current.getDate() + 1); } } *workingDays(): Generator<Date> { for (const day of this.days()) { const dow = day.getDay(); if (dow !== 0 && dow !== 6) { yield day; } } }} // Rich behavior makes client code expressiveconst q1 = DateRange.between( new Date('2024-01-01'), new Date('2024-03-31'));const vacation = DateRange.between( new Date('2024-02-15'), new Date('2024-02-22')); console.log(`Q1 has ${q1.getDurationInDays()} days`);console.log(`Vacation overlaps Q1: ${q1.containsRange(vacation)}`);console.log(`Working days in vacation: ${vacation.getWorkingDays()}`);Value objects can and should compose other value objects. This creates a rich hierarchy of domain concepts that build upon each other. The key is that the composed whole is still immutable and compares by value.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
// Simple value objects compose into complex ones // Level 1: Primitive wrappersclass Amount { private constructor(private readonly value: number) {} static of(value: number): Amount { if (value < 0) throw new NegativeAmountError(value); return new Amount(value); } getValue(): number { return this.value; } add(other: Amount): Amount { return new Amount(this.value + other.value); } subtract(other: Amount): Amount { if (other.value > this.value) throw new InsufficientAmountError(); return new Amount(this.value - other.value); }} class Percentage { private constructor(private readonly value: number) {} static of(value: number): Percentage { if (value < 0 || value > 100) throw new InvalidPercentageError(value); return new Percentage(value); } getValue(): number { return this.value; } apply(amount: Amount): Amount { return Amount.of(amount.getValue() * this.value / 100); }} // Level 2: Domain concept built from Level 1class Money { private constructor( private readonly amount: Amount, private readonly currency: Currency ) {} static of(amount: number, currency: Currency): Money { return new Money(Amount.of(amount), currency); } add(other: Money): Money { this.requireSameCurrency(other); return new Money(this.amount.add(other.amount), this.currency); } applyDiscount(discount: Percentage): Money { const discountAmount = discount.apply(this.amount); return new Money(this.amount.subtract(discountAmount), this.currency); } // ... more methods} // Level 3: Complex domain conceptclass PriceBreakdown { private constructor( private readonly subtotal: Money, private readonly tax: Money, private readonly discount: Money, private readonly shippingCost: Money ) {} static calculate( itemPrice: Money, quantity: number, taxRate: Percentage, discountRate: Percentage, shipping: Money ): PriceBreakdown { const subtotal = itemPrice.multiply(quantity); const discountedSubtotal = subtotal.applyDiscount(discountRate); const discount = subtotal.subtract(discountedSubtotal); const tax = Money.of( taxRate.apply(Amount.of(discountedSubtotal.getAmount())).getValue(), subtotal.getCurrency() ); return new PriceBreakdown(subtotal, tax, discount, shipping); } getTotal(): Money { return this.subtotal .subtract(this.discount) .add(this.tax) .add(this.shippingCost); } getSubtotal(): Money { return this.subtotal; } getTax(): Money { return this.tax; } getDiscount(): Money { return this.discount; } getShipping(): Money { return this.shippingCost; } format(): string { return ` Subtotal: ${this.subtotal.format()} Discount: -${this.discount.format()} Tax: ${this.tax.format()} Shipping: ${this.shippingCost.format()} ───────────────────────── Total: ${this.getTotal().format()} `; }}Value objects can also contain collections. The key is ensuring immutability of the collection itself:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
class Schedule { private readonly timeSlots: readonly TimeSlot[]; private constructor(timeSlots: readonly TimeSlot[]) { Schedule.validate(timeSlots); // Defensive copy - sort and freeze this.timeSlots = Object.freeze([...timeSlots].sort( (a, b) => a.getStart().getTime() - b.getStart().getTime() )); } static of(...slots: TimeSlot[]): Schedule { return new Schedule(slots); } static empty(): Schedule { return new Schedule([]); } private static validate(slots: readonly TimeSlot[]): void { // Check for overlaps const sorted = [...slots].sort( (a, b) => a.getStart().getTime() - b.getStart().getTime() ); for (let i = 0; i < sorted.length - 1; i++) { if (sorted[i].overlaps(sorted[i + 1])) { throw new OverlappingTimeSlotsError(sorted[i], sorted[i + 1]); } } } // Returns NEW schedule with added slot add(slot: TimeSlot): Schedule { return new Schedule([...this.timeSlots, slot]); } // Returns NEW schedule without the slot remove(slot: TimeSlot): Schedule { return new Schedule( this.timeSlots.filter(s => !s.equals(slot)) ); } // Query methods hasConflict(slot: TimeSlot): boolean { return this.timeSlots.some(s => s.overlaps(slot)); } findAvailableSlot(duration: Duration): TimeSlot | null { // Find first gap that fits the duration for (let i = 0; i < this.timeSlots.length - 1; i++) { const gap = this.timeSlots[i].gapUntil(this.timeSlots[i + 1]); if (gap && gap.getDuration().isGreaterOrEqual(duration)) { return TimeSlot.of(gap.getStart(), duration); } } return null; } getTotalDuration(): Duration { return this.timeSlots.reduce( (total, slot) => total.add(slot.getDuration()), Duration.zero() ); } // Immutable getter getSlots(): readonly TimeSlot[] { return this.timeSlots; // Already frozen } *[Symbol.iterator](): Iterator<TimeSlot> { yield* this.timeSlots; }} // Usageconst schedule = Schedule.of( TimeSlot.parse("09:00-10:00"), TimeSlot.parse("14:00-15:30")).add(TimeSlot.parse("11:00-12:00")); console.log(schedule.getTotalDuration().format()); // "3h 30m"console.log(schedule.hasConflict(TimeSlot.parse("09:30-10:30"))); // trueSometimes value objects have components that may or may not be present. Handling optionality while maintaining clean design requires careful consideration.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// Address with optional apartment/suiteclass Address { private readonly street: string; private readonly city: string; private readonly state: string; private readonly postalCode: PostalCode; private readonly country: Country; private readonly apartment: string | null; // Optional private readonly buildingName: string | null; // Optional private constructor( street: string, city: string, state: string, postalCode: PostalCode, country: Country, apartment: string | null, buildingName: string | null ) { // Required fields validated 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; this.apartment = apartment?.trim() || null; this.buildingName = buildingName?.trim() || null; } // Factory for minimal required fields static of( street: string, city: string, state: string, postalCode: string, country: string ): Address { return new Address( street, city, state, PostalCode.of(postalCode), Country.of(country), null, null ); } // Fluent 'with' methods for optional fields withApartment(apartment: string): Address { return new Address( this.street, this.city, this.state, this.postalCode, this.country, apartment, this.buildingName ); } withBuildingName(name: string): Address { return new Address( this.street, this.city, this.state, this.postalCode, this.country, this.apartment, name ); } // Safe access to optional fields getApartment(): string | null { return this.apartment; } hasApartment(): boolean { return this.apartment !== null; } // Formatting respects optionality formatForMailing(): string { const lines: string[] = []; if (this.buildingName) { lines.push(this.buildingName); } let streetLine = this.street; if (this.apartment) { streetLine += `, Apt ${this.apartment}`; } lines.push(streetLine); lines.push(`${this.city}, ${this.state} ${this.postalCode}`); lines.push(this.country.getDisplayName()); return lines.join('\n'); } // Equality includes optional fields 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) && this.apartment === other.apartment && this.buildingName === other.buildingName; }} // Usage - fluent API for optional componentsconst address = Address.of( "123 Main Street", "New York", "NY", "10001", "USA").withApartment("4B").withBuildingName("The Phoenix Tower");When a value object has many optional fields, consider using a builder pattern. This prevents method explosion (withX, withY, withZ...) and provides a cleaner API for construction.
Value objects often need to compute derived values. Since value objects are immutable, you can safely cache expensive computations—they'll never change.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
class Polygon { private readonly vertices: readonly Point[]; // Cached computed values (safe because immutable) private cachedArea: number | null = null; private cachedPerimeter: number | null = null; private cachedCentroid: Point | null = null; private constructor(vertices: readonly Point[]) { if (vertices.length < 3) { throw new Error("Polygon requires at least 3 vertices"); } this.vertices = Object.freeze([...vertices]); } static of(...vertices: Point[]): Polygon { return new Polygon(vertices); } // Lazy computation with caching getArea(): number { if (this.cachedArea === null) { this.cachedArea = this.calculateArea(); } return this.cachedArea; } private calculateArea(): number { // Shoelace formula let area = 0; const n = this.vertices.length; for (let i = 0; i < n; i++) { const j = (i + 1) % n; area += this.vertices[i].x * this.vertices[j].y; area -= this.vertices[j].x * this.vertices[i].y; } return Math.abs(area) / 2; } getPerimeter(): number { if (this.cachedPerimeter === null) { this.cachedPerimeter = this.calculatePerimeter(); } return this.cachedPerimeter; } private calculatePerimeter(): number { let perimeter = 0; const n = this.vertices.length; for (let i = 0; i < n; i++) { const j = (i + 1) % n; perimeter += this.vertices[i].distanceTo(this.vertices[j]); } return perimeter; } getCentroid(): Point { if (this.cachedCentroid === null) { const sumX = this.vertices.reduce((sum, p) => sum + p.x, 0); const sumY = this.vertices.reduce((sum, p) => sum + p.y, 0); this.cachedCentroid = Point.of( sumX / this.vertices.length, sumY / this.vertices.length ); } return this.cachedCentroid; } // Transformation returns new polygon (with fresh cache) scale(factor: number): Polygon { const centroid = this.getCentroid(); const scaledVertices = this.vertices.map(v => Point.of( centroid.x + (v.x - centroid.x) * factor, centroid.y + (v.y - centroid.y) * factor ) ); return new Polygon(scaledVertices); } translate(dx: number, dy: number): Polygon { return new Polygon( this.vertices.map(v => Point.of(v.x + dx, v.y + dy)) ); }} // The caching is transparent to usersconst triangle = Polygon.of( Point.of(0, 0), Point.of(4, 0), Point.of(2, 3)); console.log(triangle.getArea()); // Computedconsole.log(triangle.getArea()); // Cached (instant)console.log(triangle.getPerimeter()); // Computedconsole.log(triangle.getCentroid()); // ComputedCache derived values when: (1) computation is expensive, (2) the value is likely to be accessed multiple times, (3) memory for the cached value is acceptable. For simple calculations (e.g., string concatenation), the overhead of caching isn't worthwhile.
Well-designed value objects contribute to creating a domain-specific language (DSL) within your code. The API becomes expressive enough that code reads like domain documentation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// Expressive Duration APIclass Duration { private readonly milliseconds: number; private constructor(ms: number) { if (ms < 0) throw new Error("Duration cannot be negative"); this.milliseconds = ms; } // Named factories read like English static milliseconds(n: number): Duration { return new Duration(n); } static seconds(n: number): Duration { return new Duration(n * 1000); } static minutes(n: number): Duration { return new Duration(n * 60 * 1000); } static hours(n: number): Duration { return new Duration(n * 60 * 60 * 1000); } static days(n: number): Duration { return new Duration(n * 24 * 60 * 60 * 1000); } static weeks(n: number): Duration { return new Duration(n * 7 * 24 * 60 * 60 * 1000); } // Chaining for complex durations plus(other: Duration): Duration { return new Duration(this.milliseconds + other.milliseconds); } minus(other: Duration): Duration { return new Duration(Math.max(0, this.milliseconds - other.milliseconds)); } // Comparison reads naturally isLongerThan(other: Duration): boolean { return this.milliseconds > other.milliseconds; } isShorterThan(other: Duration): boolean { return this.milliseconds < other.milliseconds; } // Conversion toSeconds(): number { return this.milliseconds / 1000; } toMinutes(): number { return this.milliseconds / 60000; } toHours(): number { return this.milliseconds / 3600000; }} // Reading code reveals business intentconst sessionTimeout = Duration.minutes(30);const maxRetryDelay = Duration.seconds(10);const cacheExpiry = Duration.hours(24);const maintenanceWindow = Duration.hours(4).plus(Duration.minutes(30)); if (userIdleTime.isLongerThan(sessionTimeout)) { logoutUser();} // TimeSlot with natural constructionclass TimeSlot { static from(start: Time): TimeSlotBuilder { return new TimeSlotBuilder(start); }} class TimeSlotBuilder { constructor(private readonly start: Time) {} to(end: Time): TimeSlot { return TimeSlot.of(this.start, end); } lasting(duration: Duration): TimeSlot { return TimeSlot.of(this.start, this.start.plus(duration)); }} // Reads like natural languageconst meeting = TimeSlot.from(Time.of(9, 0)).to(Time.of(10, 30));const lunchBreak = TimeSlot.from(Time.of(12, 0)).lasting(Duration.hours(1)); // Money with readable arithmeticconst subtotal = Money.usd(99.99) .times(3) .plus(Money.usd(14.99)) .withDiscount(Percentage.of(10)); // DateRange with fluent APIconst fiscalYear = Year.of(2024).asDateRange();const q1 = fiscalYear.firstQuarter();const q1Workdays = q1.excludingWeekends();Duration.minutes(30) over new Duration(30, TimeUnit.MINUTES).this or new instances for fluent APIs.getValue(), use specific names like getAmountInCents().money.format(), money.formatForInvoice(), money.formatWithSymbol().We've explored the principles and patterns for designing effective value objects. Let's consolidate the key insights:
What's next:
With design patterns in hand, we'll now explore when to use value objects. The next page covers decision guidelines for recognizing value object opportunities, common mistakes in choosing between entities and value objects, and practical heuristics for your domain modeling decisions.
You now know how to design value objects that are expressive, self-validating, behaviorally rich, and composable. These patterns will help you create domain models that are both powerful and a pleasure to work with. Next, we'll learn when to apply these patterns.