| name | value-object-pattern-applied |
| description | Identify opportunities for Value Object pattern and guide their implementation. Use when refactoring primitive-heavy code. encountering grouped parameters, reviewing domain model design, or when the user mentions "primitive obsession", "value object", "VO", domain concepts, or asks to encapsulate business logic. |
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:
record OrderData(
OrderId orderId, // Order Management BC
Money total, // Order Management BC
WarehouseLocation warehouse, // Fulfillment BC
PackingInstructions packing // Fulfillment BC
) { }
record OrderSummary(OrderId orderId, Money total) { }
record FulfillmentDetails(WarehouseLocation warehouse, PackingInstructions packing) { }
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:
String isbn;
String edition;
BookIdentifier identifier = new BookIdentifier(isbn, edition);
2. Grouped Parameters
When parameters naturally travel together:
void estimateShippingCost(String country, String city, String postalCode, String street) { }
void estimateShippingCost(Address address) { }
3. Hidden Concepts
When code reveals unnamed domain concepts:
Map<String, Set<String>> booksByAuthor;
if (booksByAuthor.getOrDefault(author, Set.of()).isEmpty()) { }
AuthorCatalog catalog;
if (!catalog.hasBooks(author)) { }
4. Intermediate Computation Results
When calculations produce meaningful domain values:
BigDecimal basePrice = getPrice();
BigDecimal discount = getDiscount();
BigDecimal tax = taxFor(basePrice.subtract(discount));
BigDecimal total = basePrice.subtract(discount).add(tax);
PricingCalculator pricing = PricingCalculator.from(basePrice, discount);
BigDecimal total = pricing.totalWithTax();
5. Data with invariants
When values have validation rules or constraints:
if (!isValidISBN(rawValue)) { return null; }
Optional<ISBN> isbn = ISBN.parse(rawValue);
6. Algebraic Operations
When values can be combined, merged, or transformed:
if (userPrefs.theme == null) { userPrefs.theme = defaults.theme; }
if (userPrefs.language == null) { userPrefs.language = defaults.language; }
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) { }
private static String normalize(String value) { }
}
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) { }
public BookSearchCriteria narrowWith(BookSearchCriteria other) { }
public BookSearchCriteria withAuthor(String author) { }
}
Intention-Revealing Interface
Both class names AND method names must reveal intent:
class DataHolder {
String getValue1() { }
void setValue1(String s) { }
List<String> getList() { }
}
record ShippingSummary {
boolean isInternational() { }
Money estimatedShippingCost() { }
List<String> deliveryInstructions() { }
}
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) {
public Money grandTotal() {
return subtotal.add(tax);
}
public boolean exceedsLimit(Money limit) {
return grandTotal().isGreaterThan(limit);
}
public Money grandTotal() {
log.debug("Calculating total: {} + {}", subtotal, tax);
return subtotal.add(tax);
}
public void saveToDatabase() {
DB.save(this);
}
public Money grandTotalWithNotification() {
notificationService.send("Total calculated");
return subtotal.add(tax);
}
}
Identification Workflow
Follow this checklist when reviewing code:
Phase 1: Scan for Signals
Primitive clusters:
Structural patterns:
Behavioral patterns:
Algebraic patterns:
Phase 2: Validate Candidacy
For each candidate, verify:
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:
ShippingAddress not AddressData
-
Define fields
- Use
Optional<T> for optional values
- Use collections for variable-size data
- Make all fields
final (automatic with records)
-
Add factory methods
- Static factories:
from(), of(), parse(), etc.
- Alternative constructors for convenience
-
Implement side-effect-free domain behavior
- Queries:
isValid(), hasDiscount(), matchesFilter()
- Transformations:
withAuthor(), withoutDiscount()
- Validation:
isValidISBN(), isComplete()
- Algebra:
merge(), combine(), add(), remove()
-
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:
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:
record BookSearchSpec(
Optional<String> author,
Optional<Genre> genre,
Optional<PriceRange> priceRange) {
public BookSearchSpec(String author) {
this(Optional.of(author), Optional.empty(), Optional.empty());
}
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);
}
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:
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());
}
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:
record WishList(Set<ISBN> desiredBooks) {
public static WishList empty() { return new WishList(Set.of()); }
public static WishList of(ISBN... books) { return new WishList(Set.of(books)); }
public boolean isEmpty() { return desiredBooks.isEmpty(); }
public boolean contains(ISBN isbn) { return desiredBooks.contains(isbn); }
public int size() { return desiredBooks.size(); }
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:
record PricingBreakdown(
Money subtotal,
Optional<Discount> discount,
Money tax) {
static PricingBreakdown from(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.discountFor(subtotal));
final Money afterDiscount = discount
.map(d -> d.applyTo(subtotal))
.orElse(subtotal);
final Money tax = Tax.forAmount(afterDiscount);
return new PricingBreakdown(subtotal, discount, tax);
}
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:
record NotificationPreferences(
Optional<Boolean> emailEnabled,
Optional<Boolean> smsEnabled,
Optional<Boolean> pushEnabled,
Optional<NotificationFrequency> frequency,
Optional<QuietHours> quietHours) {
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());
}
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);
}
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));
}
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);
}
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.