Dlaczego Value Object jest ważny w DDD?
Value Object to jeden z fundamentalnych wzorców Domain-Driven Design, który pozwala na czyste modelowanie konceptów biznesowych. W świecie enterprise aplikacji, gdzie logika biznesowa jest skomplikowana, prawidłowe rozróżnienie między obiektami z tożsamością (Entity) a obiektami reprezentującymi wartości (Value Object) to klucz do czytelnego i maintainable kodu.
Co się nauczysz:
- Czym jest Value Object i czym różni się od Entity
- Jak implementować Value Object w Javie zgodnie z best practices
- Kiedy używać Value Object w praktyce biznesowej
- Jak testować Value Objects i dlaczego są łatwe w testowaniu
- Typowe pułapki przy implementacji Value Objects
Czym jest Value Object?
Value Object to obiekt, który nie ma konceptualnej tożsamości – liczy się tylko jego wartość. W przeciwieństwie do Entity, które mają unikalne ID i mogą zmieniać swoje właściwości w czasie, Value Object jest niezmienny i dwa Value Objects są równe jeśli mają takie same wartości.
Charakterystyka Value Object:
- Brak tożsamości – nie ma ID, liczy się tylko wartość
- Niezmienność (immutability) – po utworzeniu nie można zmienić wartości
- Equality przez wartość – dwa obiekty są równe jeśli mają te same wartości
- Side-effect free – operacje nie zmieniają stanu obiektu
Implementacja Value Object w Javie
Zobaczmy praktyczną implementację Value Object reprezentującego pieniądze:
public final class Money { private final BigDecimal amount; private final Currency currency; public Money(BigDecimal amount, Currency currency) { if (amount == null || currency == null) { throw new IllegalArgumentException("Amount and currency cannot be null"); } if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("Amount cannot be negative"); } this.amount = amount; this.currency = currency; } // Gettery - tylko do odczytu public BigDecimal getAmount() { return amount; } public Currency getCurrency() { return currency; } // Operacje biznesowe zwracają nowy obiekt public Money add(Money other) { if (!this.currency.equals(other.currency)) { throw new IllegalArgumentException("Cannot add different currencies"); } return new Money(this.amount.add(other.amount), this.currency); } public Money multiply(BigDecimal multiplier) { return new Money(this.amount.multiply(multiplier), this.currency); } // Equality przez wartość - KLUCZOWE! @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Money money = (Money) obj; return Objects.equals(amount, money.amount) && Objects.equals(currency, money.currency); } @Override public int hashCode() { return Objects.hash(amount, currency); } @Override public String toString() { return amount + " " + currency.getCurrencyCode(); } }
Kiedy używać Value Object?
Value Object świetnie sprawdza się do reprezentowania:
Koncept biznesowy | Dlaczego Value Object | Przykład użycia |
---|---|---|
Pieniądze | Kwota + waluta, nie ma tożsamości | Ceny produktów, salda kont |
Adres | Kombinacja ulic/miasta, wartość sama w sobie | Adresy dostawy, siedziby firm |
Okres czasu | Data od-do, liczy się zakres | Wakacje, rezerwacje, promocje |
Współrzędne GPS | Szerokość + długość geograficzna | Lokalizacje sklepów, dostaw |
Praktyczny przykład - adres:
public final class Address { private final String street; private final String city; private final String postalCode; private final String country; public Address(String street, String city, String postalCode, String country) { this.street = validateNotBlank(street, "Street"); this.city = validateNotBlank(city, "City"); this.postalCode = validateNotBlank(postalCode, "Postal code"); this.country = validateNotBlank(country, "Country"); } private String validateNotBlank(String value, String fieldName) { if (value == null || value.trim().isEmpty()) { throw new IllegalArgumentException(fieldName + " cannot be blank"); } return value.trim(); } // Metoda biznesowa - formatowanie public String getFullAddress() { return String.format("%s, %s %s, %s", street, postalCode, city, country); } // equals, hashCode, toString... }
Value Object vs Entity - kluczowe różnice
Value Object (np. Money, Address): Brak tożsamości, niezmienny, equality przez wartości
Zobaczmy to na przykładzie zamówienia w sklepie:
// Entity - ma tożsamość public class Order { private final Long id; // Unikalne ID private Money totalAmount; // Value Object jako składnik private Address deliveryAddress; // Value Object jako składnik private OrderStatus status; // Order może zmieniać stan (np. status) public void markAsPaid() { this.status = OrderStatus.PAID; } } // Value Object - brak tożsamości, reprezentuje wartość public final class Money { // Implementacja jak wyżej... }
Testowanie Value Objects
Value Objects są bardzo łatwe w testowaniu, ponieważ są przewidywalne i nie mają side-effects:
public class MoneyTest { @Test public void shouldCreateValidMoney() { // Given BigDecimal amount = new BigDecimal("100.50"); Currency currency = Currency.getInstance("EUR"); // When Money money = new Money(amount, currency); // Then assertEquals(amount, money.getAmount()); assertEquals(currency, money.getCurrency()); } @Test public void shouldBeEqualWhenSameValues() { // Given Money money1 = new Money(new BigDecimal("50"), Currency.getInstance("USD")); Money money2 = new Money(new BigDecimal("50"), Currency.getInstance("USD")); // Then assertEquals(money1, money2); assertEquals(money1.hashCode(), money2.hashCode()); } @Test public void shouldAddMoneyCorrectly() { // Given Money money1 = new Money(new BigDecimal("100"), Currency.getInstance("PLN")); Money money2 = new Money(new BigDecimal("50"), Currency.getInstance("PLN")); // When Money result = money1.add(money2); // Then assertEquals(new BigDecimal("150"), result.getAmount()); assertEquals(Currency.getInstance("PLN"), result.getCurrency()); } }
Najczęstsze pułapki
Integracja z frameworkami
W Springu 5.x (2019) Value Objects można łatwo integrować z JPA:
@Entity public class Product { @Id private Long id; @Embedded private Money price; // Value Object jako embedded @Embedded @AttributeOverrides({ @AttributeOverride(name = "street", column = @Column(name = "warehouse_street")), @AttributeOverride(name = "city", column = @Column(name = "warehouse_city")) }) private Address warehouseAddress; } @Embeddable public class Money { private BigDecimal amount; private String currencyCode; // konstruktory, gettery... }
Tak! Value Object może składać się z innych Value Objects. Na przykład DateRange może zawierać dwa obiekty LocalDate. To bardzo naturalne i zgodne z DDD.
Walidacja powinna być w konstruktorze. Value Object powinien zawsze być w prawidłowym stanie - jeśli da się go utworzyć, to znaczy że jest poprawny. Rzucaj IllegalArgumentException dla nieprawidłowych danych.
Zdecydowanie tak! Value Object to idealne miejsce na logikę związaną z daną wartością. Na przykład Money.add() czy Address.isInSameCity() to naturalne metody biznesowe.
Nowoczesne JVM-y bardzo dobrze optymalizują obiekty immutable. Korzyści z czystszego kodu i łatwiejszego testowania przewyższają minimalny overhead. Używaj Value Objects gdzie mają sens biznesowy.
Używaj EqualsVerifier library lub pisz testy ręcznie sprawdzając: refleksywność (x.equals(x)), symetryczność (x.equals(y) ⇔ y.equals(x)), przechodniość i zgodność z hashCode().
Technicznie tak, ale unikaj tego. Value Object powinien być konkretny i reprezentować konkretną wartość biznesową. Jeśli potrzebujesz abstrakcji, przemyśl czy to naprawdę Value Object.
Value Object nie powinien akceptować null w konstruktorze. Zamiast tego używaj Optional lub wzorca Null Object. Na przykład Money.ZERO zamiast null dla zerowej kwoty.
Przydatne zasoby:
- Java Objects utility class documentation
- Martin Fowler o Value Object
- EqualsVerifier - testowanie equals/hashCode
- Spring Framework 5.2 Documentation
🚀 Zadanie dla Ciebie
Stwórz Value Object reprezentujący przedział czasowy (TimeRange) z datą początkową i końcową. Dodaj metody: overlaps(TimeRange other), contains(LocalDate date) i getDurationInDays(). Napisz testy jednostkowe sprawdzające wszystkie przypadki brzegowe. Zadbaj o walidację - data końcowa nie może być wcześniejsza niż początkowa!
Czy masz doświadczenie z implementacją Value Objects w swoich projektach? Jakie wyzwania napotkałeś przy modelowaniu domeny biznesowej?