Value Object pattern w DDD – Podstawy i praktyka

TL;DR: Value Object to wzorzec z DDD, który reprezentuje wartości bez tożsamości. W przeciwieństwie do Entity, Value Object jest niezmienny, porównywany przez wartość i łatwy do testowania. Idealny do modelowania pieniędzy, adresów czy współrzędnych.

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
Wymagania wstępne: Podstawowa znajomość Javy (klasy, konstruktory, equals/hashCode), znajomość podstaw programowania obiektowego. Znajomość Domain-Driven Design będzie pomocna ale nie konieczna.

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.

Wyobraź sobie pieniądze: dwa banknoty 50 złotych to ta sama wartość, niezależnie od ich numerów seryjnych. To Value Object. Natomiast Twoje konto bankowe ma unikalny numer – to Entity, bo nawet gdyby miało taki sam balans jak inne konto, to nadal jest inne konto.

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();
    }
}
Pro tip: Zawsze używaj final dla klasy Value Object i wszystkich jej pól. To gwarantuje niezmienność i sprawia, że kompilator pomoże Ci wyłapać błędy.

Kiedy używać Value Object?

Value Object świetnie sprawdza się do reprezentowania:

Koncept biznesowyDlaczego Value ObjectPrzykład użycia
PieniądzeKwota + waluta, nie ma tożsamościCeny produktów, salda kont
AdresKombinacja ulic/miasta, wartość sama w sobieAdresy dostawy, siedziby firm
Okres czasuData od-do, liczy się zakresWakacje, rezerwacje, promocje
Współrzędne GPSSzerokość + długość geograficznaLokalizacje 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...
}
Typowy błąd początkujących: Tworzenie setterów w Value Object. Pamiętaj - Value Object musi być niezmienny! Zamiast setterów twórz metody zwracające nowy obiekt z zmienionymi wartościami.

Value Object vs Entity - kluczowe różnice

Entity (np. User, Order): Ma unikalną tożsamość (ID), może zmieniać stan, equality przez ID
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());
    }
}
Pro tip: Testuj Value Objects zawsze przez behavior, nie przez implementację. Sprawdzaj czy biznesowe operacje dają oczekiwane rezultaty.

Najczęstsze pułapki

Pułapka: Używanie Value Object jako Entity. Jeśli potrzebujesz śledzić historię zmian obiektu lub ma on cykl życia, to prawdopodobnie potrzebujesz Entity, nie Value Object.
Uwaga: Nie wszystko co wydaje się "wartością" to Value Object. Email użytkownika może wyglądać jak wartość, ale jeśli służy jako unikalny identyfikator, to część Entity User.

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...
}
Czy Value Object może zawierać inne Value Objects?

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.

Jak radzić sobie z walidacją w Value Object?

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.

Czy mogę mieć logikę biznesową w Value Object?

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.

Co z performance - czy Value Objects nie są zbyt kosztowne?

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.

Jak testować equals() i hashCode() w Value Object?

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().

Czy Value Object może być abstrakcyjny?

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.

Jak radzić sobie z null w 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:

🚀 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?

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry