Wprowadzenie Value Object do projektu to mały krok w kierunku poprawienia wewnętrznej struktury oprogramowania. Tak naprawdę to tylko wykorzystanie podstaw programowania obiektowego - stworzenie niemutowalnej klasy reprezentującej dany byt biznesowy. A jednak Value Object jest ciągle niewykorzystywany w wielu projektach. Ubolewam nad tym stanem rzeczy. Z tego powodu chcę pokazać praktyczny przykład wprowadzenia Value Object do projektu.

Załóżmy, że mamy klasę jak poniżej. Jej odpowiedzialnością jest wyliczenie składki na podstawie dwóch danych: numeru VIN i numeru rejestracyjnego (przykład jest specjalnie bardzo uproszony). Numer VIN i numer rejestracyjny są reprezentowane przez typ prymitywny String. Dodatkowo na numerze VIN dokonywana jest weryfikacja jego poprawności. Następnie obydwie wartości są przekazywane dalej do innego serwisu, który komunikuje się z zewnętrznym dostawcą.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RequiredArgsConstructor
class QuoteCalculator {

  private final ExternalServiceQuoteCalculator externalServiceQuoteCalculator;

  double calculateQuoteFor(String vinNumber, String registrationNumber) {
    if (vinNumber == null) {
      throw new IllegalArgumentException("vin number cannot be null");
    }
    if (vinNumber.trim().length() != 17) {
      throw new IllegalArgumentException("vin number should contains 17 letters");
    }
    return externalServiceQuoteCalculator.calculate(vinNumber, registrationNumber);
  }

  private class ExternalServiceQuoteCalculator {

    double calculate(String vinNumber, String registrationNumber) {
      // call external API
      return 0.0;
    }

  }
  
}

Ja widzę tutaj dwa problemy. Pierwszym z nich jest to, że metoda calculate klasy ExternalServiceQuoteCalculator nie wie czy numer VIN ma prawidłową wartość. Najprawdopodobniej zduplikuje wewnątrz logikę walidacyjną. Zachaczamy więc o brzydki zapach duplikacji kodu. Drugi problem to możliwość pomylenia się podczas przekazywania wartości dalej, do innych serwisów. Kompilator nas przed tym nie uchroni… Chociaż ostatnio zauważyłem, że IntelliJ dodaje ostrzeżenie o tym, że jest całkiem możliwe, że doszło do pomyłki. Jednak w tej kwestii wolałbym nie polegać na środowisku deweloperskim. Dlatego dobrym pomysłem okazuje się wprowadzenie konceptu takiego jak Value Object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class VinNumber {

  private final String value;

  public VinNumber(String value) {
    if (value == null) { // 1
      throw new IllegalArgumentException("vin number cannot be null");
    }
    if (value.trim().length() != 17) {
      throw new IllegalArgumentException("vin number should contains 17 letters");
    }
    this.value = value;
  }

  public String asString() { // 2
    return value;
  }

}

Tworzymy nową klasę reprezentującą numer rejestracyjny - VinNumber. W konstrukturze umieszamy walidację (1) oraz tworzymy metodę konwertującą naszą nową klasę na stary kontrakt (2). W ten sposób mamy pewność, że nowopowstały obiekt będzie znajdował się w prawidłowym stanie oraz dzięki metodzie asString nie musimy zbawiać od razu całego świata podczas jednego refaktoringu. Kiełkowanie naszej nowej klasy w starym kodzie może wyglądać następująco.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequiredArgsConstructor
class QuoteCalculator {

  private final ExternalServiceQuoteCalculator externalServiceQuoteCalculator;

  double calculateQuoteFor(String vinNumberValue, String registrationNumber) {
    VinNumber vinNumber = new VinNumber(vinNumberValue);
    return externalServiceQuoteCalculator.calculate(vinNumber.asString(), registrationNumber);
  }

  private class ExternalServiceQuoteCalculator {

    double calculate(String vinNumber, String registrationNumber) {
      // call external API
      return 0.0;
    }

  }
}

Klienty klasy QuoteCalculator nic się nie dowiedziały o naszej zmianie. Wszystko na zewnętrz nie zostało w najmniejszym stopniu naruszone. Natomiast zmieniła się tylko wewnętrzna struktura metody calculateQuoteFor. Niby nic, a jednak już bardzo dużo się zadziało. Puryści oczywiście mogą nie wytrzymać takiego widoku. Jednak pamiętajmy, nie ratujemy od razu całego świata. Aby coś było “ładniejsze” najpierw musi być “brzydkie”, czy jakoś tak.

Przydatnym efektem ubocznym jest zwiększenie testowalności. Teraz nie musimy zestawiać obiektu naszego serwisu QuoteCalculator tylko po to, aby przetestować walidację numeru VIN. Wystarczy stworzyć dedykowany do tego obiekt klasy VinNumber.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class VinNumberTest {

  @Test
  void cannot_create_null_vin_number() {
    assertThrows(IllegalArgumentException.class, () -> new VinNumber(null));
  }
  
  @ParameterizedTest
  @CsvSource({"0123456789012345", "012345678901234567"})
  void cannot_create_vin_number_with_wrong_length(String value) {
    assertThrows(IllegalArgumentException.class, () -> new VinNumber(value));
  }
  
}

Jaki mógłby być kolejny krok? Moglibyśmy dalej propagować nasz obiekt. Wystarzyłoby przeciążyć metodę, aby zamiast String vinNumber przyjmowała obiekt klasy VinNumber. Znowu, stare klienty nie ucierpiały na tym kroku, a nowe mogłby już korzystać z przyjemniejszego API.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequiredArgsConstructor
class QuoteCalculator {

  private final ExternalServiceQuoteCalculator externalServiceQuoteCalculator;

  double calculateQuoteFor(VinNumber vinNumber, String registrationNumber) {
    return externalServiceQuoteCalculator.calculate(vinNumber.asString(), registrationNumber);
  }

  double calculateQuoteFor(String vinNumberValue, String registrationNumber) {
    return calculateQuoteFor(new VinNumber(vinNumberValue), registrationNumber);
  }

  private class ExternalServiceQuoteCalculator {

    double calculate(String vinNumber, String registrationNumber) {
      // call external API
      return 0.0;
    }

  }
}

I tak dalej, i tak dalej… aż doszlibyśmy do satysfakcjonującego nas efektu końcowego. Co o tym sądzisz? Komplikowanie czy jednak to podejście niesie ze sobą dużą wartość?