Value Object Pattern Applied
This skill helps identify, design, implement, and verify Domain-Driven Design Value Objects (VOs).
Quick Triage
Use a Value Object when:
- •A domain concept is represented by primitives with scattered validation.
- •A parameter cluster always travels together across methods.
- •You need domain behavior around a small immutable concept.
What is a Value Object?
A Value Object represents a descriptive aspect of the domain with no conceptual identity. It:
- •Encapsulates one or more related values with domain meaning
- •Is immutable (final fields, no setters)
- •Defines equality by value, not identity
- •Contains domain logic related to those values
- •Provides side-effect-free functions (pure functions with no side effects)
- •Has an intention-revealing interface (both class name and method names)
- •Often provides algebraic operations (merge, combine, transform, add, subtract)
- •Lives within a single Bounded Context - all concepts belong to the same domain context
Strategic DDD: Bounded Context Boundary
IMPORTANT: A Value Object should encapsulate concepts from one specific Bounded Context.
Value Objects are tactical patterns that naturally live within a single BC. This guidance protects against unconsciously mixing concerns from different contexts:
// ❌ BAD: Mixes Order Management and Fulfillment Contexts
record OrderData(
OrderId orderId, // Order Management BC
Money total, // Order Management BC
WarehouseLocation warehouse, // Fulfillment BC
PackingInstructions packing // Fulfillment BC
) { }
// ✅ GOOD: Separated by Bounded Context
record OrderSummary(OrderId orderId, Money total) { } // Order Management BC
record FulfillmentDetails(WarehouseLocation warehouse, PackingInstructions packing) { } // Fulfillment BC
Note: Shared Kernel concepts (like Money, Address) may appear across multiple BCs when teams agree on shared definitions.
When to Create a Value Object
1. Primitive Obsession
Replace primitives that carry domain meaning:
// Before: Primitive obsession String isbn; String edition; // After: Value Object BookIdentifier identifier = new BookIdentifier(isbn, edition);
2. Grouped Parameters
When parameters naturally travel together:
// Before: Parameter clusters
void calculateShipping(String country, String city, String postalCode, String street) { }
// After: Cohesive Value Object
void calculateShipping(Address address) { }
3. Hidden Concepts
When code reveals unnamed domain concepts:
// Before: Map exposes structure, hides intent
Map<String, Set<String>> booksByAuthor;
if (booksByAuthor.getOrDefault(author, Set.of()).isEmpty()) { }
// After: Named concepts with domain behavior
AuthorCatalog catalog;
if (!catalog.hasBooks(author)) { }
4. Intermediate Computation Results
When calculations produce meaningful domain values:
// Before: Scattered pricing logic BigDecimal basePrice = getPrice(); BigDecimal discount = getDiscount(); BigDecimal tax = calculateTax(basePrice.subtract(discount)); BigDecimal total = basePrice.subtract(discount).add(tax); // After: Named intermediate concept PricingCalculator pricing = PricingCalculator.from(basePrice, discount); BigDecimal total = pricing.totalWithTax();
5. Data with invariants
When values have validation rules or constraints:
// Before: Validation scattered across codebase
if (!isValidISBN(rawValue)) { return null; }
// After: Validation encapsulated in VO
Optional<ISBN> isbn = ISBN.parse(rawValue); // return empty if invalid
6. Algebraic Operations
When values can be combined, merged, or transformed:
// Before: Manual field-by-field logic
if (userPrefs.theme == null) { userPrefs.theme = defaults.theme; }
if (userPrefs.language == null) { userPrefs.language = defaults.language; }
// After: Algebraic operation
UserPreferences prefs = userPreferences.merge(defaults);
Implementation Pattern (Java Records)
Use Java records for Value Objects with intention-revealing interface. Choose pattern based on complexity:
Minimal VO (1-2 fields):
record ISBN(String value) {
public static Optional<ISBN> parse(String raw) {
return isValid(raw) ? Optional.of(new ISBN(normalize(raw))) : Optional.empty();
}
private static boolean isValid(String value) { /* validation */ }
private static String normalize(String value) { /* formatting */ }
}
Standard VO (2-4 fields):
record PriceRange(Money min, Money max) {
public static Optional<PriceRange> from(Money min, Money max) {
return min.isLessThanOrEqual(max)
? Optional.of(new PriceRange(min, max))
: Optional.empty();
}
public boolean contains(Money price) {
return price.isGreaterThanOrEqual(min) && price.isLessThanOrEqual(max);
}
public Money midpoint() {
return min.add(max).divide(2);
}
}
Rich VO (multiple fields, algebra, transformations):
record BookSearchCriteria(
Optional<String> author,
Optional<Genre> genre,
Optional<PriceRange> priceRange) {
public boolean isSatisfiedBy(Book book) { /* matching logic */ }
public BookSearchCriteria narrowWith(BookSearchCriteria other) { /* merge */ }
public BookSearchCriteria withAuthor(String author) { /* transformation */ }
}
Intention-Revealing Interface
Both class names AND method names must reveal intent:
// ❌ BAD: Technical names, reveals implementation
class DataHolder {
String getValue1() { }
void setValue1(String s) { }
List<String> getList() { }
}
// ✅ GOOD: Intention-revealing names, reveals purpose
record ShippingSummary {
boolean isInternational() { } // not: getCountryCode() != "US"
Money estimatedShippingCost() { } // not: calculatePrice()
List<String> deliveryInstructions() { } // not: getStrings()
}
Side-Effect-Free Functions
Value Objects methods should be side-effect-free (pure functions):
- •No external state modification
- •No I/O operations (database, network, file system)
- •Deterministic (same input -> same output)
- •Safe to call multiple times
- •Thread safe
- •Note: Logging is generally acceptable and not considered a problematic side effect
record OrderTotal(Money subtotal, Money tax) {
// ✅ GOOD: Side-effect-free
public Money grandTotal() {
return subtotal.add(tax); // Pure calculation
}
public boolean exceedsLimit(Money limit) {
return grandTotal().isGreaterThan(limit); // Pure comparison
}
// ✅ Acceptable: Logging for debugging
public Money grandTotal() {
log.debug("Calculating total: {} + {}", subtotal, tax);
return subtotal.add(tax);
}
// ❌ BAD: Has side effects
public void saveToDatabase() { // I/O operations - mutates external state
DB.save(this);
}
public Money grandTotalWithNotification() { // Side effect: sends notification
notificationService.send("Total calculated");
return subtotal.add(tax);
}
}
Identification Workflow
Follow this checklist when reviewing code:
Phase 1: Scan for Signals
Primitive clusters:
- • Multiple primitive parameters that always travel together
- • Primitives with validation logic scattered across methods
- • String/int/long that represents domain concepts (IDs, specifications, constraints)
Structural patterns:
- • Maps/Collections with specific access patterns
- • Repeated
getOrDefault(),computeIfAbsent(), or null checks - • Data structures exposing implementation details
Behavioral patterns:
- • Calculation that produces intermediate results with domain meaning
- • Validation logic that could be encapsulated
- • Domain queries scattered across multiple methods
- • Repeated field-by-field merging or copying logic
Algebraic patterns:
- • Code that combines or merges instances field-by-field
- • Transformations creating modified copies (with/without methods)
- • Fallback/default value resolution logic
- • Collection operations (add, remove, union, intersection)
- • Builder-like chaining of modifications
Phase 2: Validate Candidacy
For each candidate, verify:
- • Has clear domain meaning (not just technical grouping)
- • Equality is based on values, not identity
- • Can be immutable (no need for setters)
- • Contains or could contain domain logic
- • Name captures domain concepts, not structure
- • All concepts belong to the same Bounded Context
Phase 3: Design the Value Object
- •
Verify Bounded Context boundary
- •All fields must belong to the same domain context
- •If mixing context, split into separate VOs
- •
Choose an intention-revealing name
- •Captures WHAT it represents, not HOW it's structured
- •Example:
ShippingAddressnotAddressData
- •
Define fields
- •Use
Optional<T>for optional values - •Use collections for variable-size data
- •Make all fields
final(automatic with records)
- •Use
- •
Add factory methods
- •Static factories:
from(),of(),parse(), etc. - •Alternative constructors for convenience
- •Static factories:
- •
Implement side-effect-free domain behavior
- •Queries:
isValid(),hasDiscount(),matchesFilter() - •Transformations:
withAuthor(),withoutDiscount() - •Validation:
isValidISBN(),isComplete() - •Algebra:
merge(),combine(),add(),remove()
- •Queries:
- •
Use intention-revealing method names
- •Method names should express business intent
- •Avoid technical names like
get(),set(),calculate()
- •
Consider toString()
- •Override if default record format doesn't match domain representation
Phase 4: Implementation
- •Create the record
- •Add factory methods if applicable
- •Migrate domain logic from scattered locations
- •Add side-effect-free functions if applicable
- •Add algebraic operations if applicable
- •Replace usage sites
- •Remove now-unused parameter clusters
Phase 5: Verification
Verify the implementation:
- • All fields are immutable
- • All concepts belong to same Bounded Context
- • No business logic leaked outside VO
- • All methods are side-effect-free
- • Method names are intention-revealing
- • Callers use domain methods, not field accessors
- • Tests cover domain behavior, not just construction
- • Name clearly communicates domain concept
- • Algebraic operations maintain immutability
Red Flags (When NOT to Create a VO)
Avoid creating Value Objects when:
- •Pure data transfer: If it's only for serialization/deserialization with no domain logic
- •Identity matters: If two instances with same values should be different (use Entity instead)
- •Mutable by nature: If the concept inherently requires state changes (consider Entity)
- •No domain meaning: If it's just technical grouping without domain significance
- •Single primitive: Unless it has rich validation/behavior (then consider Tiny Type pattern)
- •Crosses Bounded Contexts: If fields belong to different domain contexts, split the VO
Pattern Catalog
Pattern 1: Specification Pattern
Encapsulates matching/filtering criteria with rich behavior:
/**
* Specifies book search criteria with optional constraints.
* Empty constraints match any value; specific constraints must match exactly.
*/
record BookSearchSpec(
Optional<String> author,
Optional<Genre> genre,
Optional<PriceRange> priceRange) {
// Convenience constructor
public BookSearchSpec(String author) {
this(Optional.of(author), Optional.empty(), Optional.empty());
}
// Side-effect-free domain query (intention-revealing!)
public boolean isSatisfiedBy(Book book) {
return matchesAuthor(book) && matchesGenre(book) && matchesPrice(book);
}
private boolean matchesAuthor(Book book) {
return author.map(a -> book.author().equals(a)).orElse(true);
}
private boolean matchesGenre(Book book) {
return genre.map(g -> book.genre().equals(g)).orElse(true);
}
private boolean matchesPrice(Book book) {
return priceRange.map(pr -> pr.contains(book.price())).orElse(true);
}
// Algebraic operations
public BookSearchSpec narrowWith(BookSearchSpec additional) {
return new BookSearchSpec(
author.or(() -> additional.author),
genre.or(() -> additional.genre),
priceRange.or(() -> additional.priceRange));
}
}
Pattern 2: Focused Extraction Pattern
Extracts specific aspects from a larger structure into a cohesive VO for focused operations:
/**
* Represents a book's availability and stocking information.
* Extracted from full Book entity to focus on inventory concerns.
*/
record BookAvailability(
ISBN isbn,
int quantityInStock,
int quantityReserved,
Optional<RestockDate> nextRestockDate) {
static BookAvailability from(Book book, InventoryRecord inventory) {
return new BookAvailability(
book.isbn(),
inventory.currentStock(),
inventory.reservedQuantity(),
inventory.plannerRestock());
}
// Side-effect-free domain queries (intention-revealing!)
int availableForSale() {
return Math.max(0, quantityInStock - quantityReserved);
}
boolean isAvailable() {
return availableForSale() > 0;
}
boolean needsRestock() {
return availableForSale() < 5 && nextRestockDate.isEmpty();
}
Optional<LocalDate> estimatedAvailableDate() {
return isAvailable()
? Optional.of(LocalDate.now())
: nextRestockDate.map(RestockDate::date);
}
}
Pattern 3: Collection Wrapper with Rich Algebra
Wraps collections with domain-specific operation and algebraic API:
/**
* Wish list containing desired books for future purchase.
* Immutable collection with domain-specific operations.
* Note: If this needs identity (per-user wish list), consider making it an Entity instead.
*/
record WishList(Set<ISBN> desiredBooks) {
// Factory methods
public static WishList empty() { return new WishList(Set.of()); }
public static WishList of(ISBN... books) { return new WishList(Set.of(books)); }
// Side-effect-free queries (intention-revealing!)
public boolean isEmpty() { return desiredBooks.isEmpty(); }
public boolean contains(ISBN isbn) { return desiredBooks.contains(isbn); }
public int size() { return desiredBooks.size(); }
// Algebraic operations: add, remove, merge, filter
public WishList add(ISBN isbn) {
final var updated = new HashSet<>(desiredBooks);
updated.add(isbn);
return new WishList(updated);
}
public WishList remove(ISBN isbn) {
final var updated = new HashSet<>(desiredBooks);
updated.remove(isbn);
return new WishList(updated);
}
public WishList mergeWith(WishList other) {
final var updated = new HashSet<>(this.desiredBooks);
updated.addAll(other.desiredBooks);
return new WishList(updated);
}
public WishList keepOnly(Set<Genre> allowedGenres, Function<ISBN, Genre> genreLookup) {
return new WishList(desiredBooks.stream()
.filter(isbn -> allowedGenres.contains(genreLookup.apply(isbn)))
.collect(Collectors.toSet()));
}
}
Pattern 4: Calculation Encapsulation
Encapsulates multi-step calculations with intermediate results:
/**
* Encapsulates order pricing calculation with breakdown.
* Maintains intermediate values for transparency and debugging.
*/
record PricingBreakdown(
Money subtotal,
Optional<Discount> discount,
Money tax) {
static PricingBreakdown calculate(List<LineItem> items, Optional<DiscountCode> code) {
final Money subtotal = items.stream()
.map(LineItem::total)
.reduce(Money.zero(), Money::add);
final Optional<Discount> discount = code
.flatMap(c -> c.calculateDiscount(subtotal));
final Money afterDiscount = discount
.map(d -> d.applyTo(subtotal))
.orElse(subtotal);
final Money tax = Tax.calculate(afterDiscount);
return new PricingBreakdown(subtotal, discount, tax);
}
// Side-effect-free queries (intention revealing!)
Money grandTotal() {
return amountAfterDiscount().add(tax);
}
Money amountAfterDiscount() {
return discount
.map(d -> d.applyTo(subtotal))
.orElse(subtotal);
}
Money totalSavings() {
return discount.map(Discount::amount).orElse(Money.zero());
}
boolean hasDiscount() {
return discount.isPresent();
}
}
Pattern 5: Configuration with Merge Semantics
Settings or preferences with rich merge and override algebra:
/**
* User notification preferences with configurable channels and frequency.
* Supports merging with defaults and selective overrides.
*/
record NotificationPreferences(
Optional<Boolean> emailEnabled,
Optional<Boolean> smsEnabled,
Optional<Boolean> pushEnabled,
Optional<NotificationFrequency> frequency,
Optional<QuietHours> quietHours) {
// Factory methods
public static NotificationPreferences defaults() {
return new NotificationPreferences(
Optional.of(true),
Optional.of(false),
Optional.of(true),
Optional.of(NotificationFrequency.IMMEDIATE),
Optional.empty());
}
public static NotificationPreferences allDisabled() {
return new NotificationPreferences(
Optional.of(false),
Optional.of(false),
Optional.of(false),
Optional.empty(),
Optional.empty());
}
// Side-effect-free queries (intention-revealing!)
public boolean hasAnyChannelEnabled() {
return emailEnabled.orElse(false) || smsEnabled.orElse(false) || pushEnabled.orElse(false);
}
public boolean shouldNotifyNow(LocalTime currentTime) {
if (!hasAnyChannelEnabled()) return false;
return quietHours.map(qh -> !qh.isWithin(currentTime)).orElse(true);
}
// Algebraic operations: merge with fallback
public NotificationPreferences merge(NotificationPreferences fallback) {
return new NotificationPreferences(
emailEnabled.or(() -> fallback.emailEnabled),
smsEnabled.or(() -> fallback.smsEnabled),
pushEnabled.or(() -> fallback.pushEnabled),
frequency.or(() -> fallback.frequency),
quietHours.or(() -> fallback.quietHours));
}
// Algebraic operations: override specific settings
public NotificationPreferences withEmailEnabled(boolean enabled) {
return new NotificationPreferences(Optional.of(enabled), smsEnabled, pushEnabled, frequency, quietHours);
}
public NotificationPreferences withFrequency(NotificationFrequency frequency) {
return new NotificationPreferences(emailEnabled, smsEnabled, pushEnabled, Optional.of(frequency), quietHours);
}
public NotificationPreferences withoutQuietHours() {
return new NotificationPreferences(emailEnabled, smsEnabled, pushEnabled, frequency, Optional.empty());
}
}
See Also: The factory-pattern-applied skill for dedicated factory guidance.
Communication Template
When proposing a Value Object to the user:
I've identified a Value Object pattern to be applied: **[Name]** **Current state**: [Brief description of primitive/scattered logic] **Bounded Context**: [Which domain context this belongs to] **Proposed VO**: - Name: `[ValueObjectName]` - Fields: `[field list with types]` - Key behavior: [main domain methods with intention-revealing names] **Benefits** - Encapsulates: [what domain logic] - Eliminates: [what code smell] - Clarifies: [what domain concept] **Impact**: [number] usage sites would be simplified Proceed with implementation?
Testing Value Objects
Test domain behavior, not just construction:
class WishListTest {
@Test
void should_add_book_to_wish_list() {
final var wishList = WishList.empty();
final var isbn = givenISBN();
final var updated = wishList.add(isbn);
assertThat(updated.contains(isbn)).isTrue();
assertThat(updated.size()).isEqualTo(1);
}
@Test
void should_merge_wish_lists_without_duplicates() {
final var sharedISBN = givenISBN("978-0-123456-78-9");
final var list1 = wishList.of(sharedISBN, givenISBN("978-1-111111-11-1"));
final var list2 = wishList.of(sharedISBN, givenISBN("978-2-222222-22-2"));
final var merged = list1.mergeWith(list2);
assertThat(merged.size()).isEqualTo(3); // Not 4, no duplicates
}
private ISBN givenISBN() {
return new ISBN("978-0-123456-78-9");
}
private ISBN givenISBN(String value) {
return new ISBN(value);
}
}
Quick Reference
Creation signals: Primitive clusters, repeated validation, hidden concepts, intermediate results, merge/combine logic, collection operations Design keys: Intention-revealing names (class + methods), immutability, side-effect-free functions, Optional for optionals, algebraic operations, single Bounded Context Red flags: No domain meaning, identity matters, requires mutation, pure DTO, crosses Bounded Contexts Implementation: Java record + factory methods + side-effect-free functions + algebra + toString override Verification: Immutable, single BC, encapsulated logic, intention-revealing interface, side-effect-free, tested
Workflow Summary
- •Scan: Look for primitive clusters, map wrappers, scattered validation, merge logic, collection operations
- •Validate: Verify domain meaning, value equality, immutability potential, single Bounded Context
- •Design: Name concept, define fields from the same BC. identify side-effect-free operations and algebra
- •Implement: Record + factories + side-effect-free behavior + algebraic operations + intention-revealing names + tests
- •Verify: Immutable, single BC, encapsulated, intention-revealing interface, side-effect-free, tested, clear concept
Note: When this skill is invoked, prefix and suffix all responses with the hexagon emoji ⌬. This rule can be combined freely with other similar emoji rules from other skills and rules.
If you find this skill valuable and want expert guidance on applying Domain-Driven Design, CQRS, Event Sourcing, and other design patterns in your project, visit [patternapplied.com/en] (https://patternapplied.com/en) or contact Piotr to discuss how these patterns can transform your architecture.