Skip to content

Problem zmiennej reprezentacji — Money

W przypadku numeru prawa jazdy mieliśmy do czynienia z duplikacją konkretnej logiki walidacyjnej. W podobny sposób możemy też rozwiązać inne problemy.

Spójrz na ten kawałek kodu:

public class Transit extends BaseEntity {

   private Integer price;

   private Integer estimatedPrice;

   private Integer driversFee;

   //...
}

Wartość pieniężną reprezentuje typ Integer. Zakładamy dobre intencje autora, który prawdopodobnie zetknął się wcześniej z problemem zaokrągleń wartości zmiennoprzecinkowych.

Popatrz także na niektóre operacje dokonywane na pieniądzach:

public class Transit extends BaseEntity {

    private Integer calculateCost() {

        //...       

        BigDecimal priceBigDecimal = 
                   new BigDecimal(km * kmRate * factorToCalculate + baseFee)
                                         .setScale(2, RoundingMode.HALF_UP);
        int finalPrice = Integer
                            .parseInt(String.valueOf(priceBigDecimal)
                            .replaceAll("\\.", ""));
        this.price = finalPrice;
        return this.price;
    }

    //...  
}

Obliczamy tutaj cenę przejazdu, znając stawkę początkową, dystans i stawkę za kilometr. Na końcu musimy przedstawić wynik jako typ Integer, bo tak rozumiane są pieniądze w większości miejsc w naszym systemie. Być może do tej pory kod był stabilny oraz nie musieliśmy tego typu pieniężnej logiki rozsiewać po wielu miejscach w systemie. Musimy się jednak zmierzyć się z innym problemem.

Nadchodzące wdrożenia na nowe rynki powodują potrzeby wprowadzenia konceptu waluty, a nawet rozliczeń międzywalutowych. Klient posiadający konto w aplikacji będzie mógł zamówić taksówkę w innym kraju, płacąc przy tym w swojej narodowej walucie. A w trochę dalszej przyszłości – nawet w jednej z popularnych kryptowalut.

Wnioski z EventStormingu

To oznacza, że wszędzie tam, gdzie do tej pory posługiwaliśmy się samą liczbą, a informacja o walucie była pewnie dodawana tylko na potrzeby warstwy prezentacji, należy właśnie tę walutę dodać.

Innym miejscem w naszym systemie, w którym liczymy wartości pieniężne, jest honorarium dla kierowcy, w klasie DriverFeeService:

public class DriverFeeService {

    public Integer calculateDriverFee(Long transitId) {
        // ...

        Integer finalFee;
        if (driverFee.getFeeType().equals(DriverFee.FeeType.FLAT)) {
             finalFee = transitPrice - driverFee.getAmount();
        } else {
            finalFee = transitPrice * driverFee.getAmount() / 100;

        }

        return Math.max(finalFee, driverFee.getMin() == null ? 
                                                0 : driverFee.getMin());
    }
}

To miejsce oraz pewnie w przyszłości też to odpowiedzialne za fakturowanie, powinno być wzbogacone o informację o walucie, która może być dowolna. Będzie się różnić per wdrożenie, a być może nawet w cyklu obsługi pojedynczego kierowcy, który zmieni swoją walutę rozliczeniową.

Jak widać na podstawie DriverFeeService, kwoty często poddawane są operacjom dodawania, odejmowania, czy też liczenia konkretnego procentu. Implikuje to sposób liczenia finalnego rezultatu – w niektórych krajach kwoty podaje się do trzech miejsc po przecinku. To kolejne wyzwanie, z którym trzeba będzie się zmierzyć.

Podsumowując: w tym momencie nie walczymy z duplikacją kodu. Mamy tutaj problem ze zmienną reprezentacją danego konceptu (pieniądza). Ta reprezentacja jest widoczna w wielu miejscach systemu. Brzmi znajomo? Do tej pory wystarczyła reprezentacja w typie liczby całkowitej. Teraz w każde z miejsc musimy też dodać walutę. Spodziewamy się wielu różnych walut. W przyszłości być może zmienimy też obecną reprezentację na coś bardziej pasującego do koncepcji, np. z powodów wydajnościowych.

❓ Jaki problem rozwiązujemy?

Zmienności reprezentacji konceptu domenowego.

Jak nasz problem rozwiązać?

String

Moglibyśmy w każdym z miejsc, w których używamy kwot w postaci liczb całkowitych, dodać walutę typu String lub też w typie wyliczeniowym:

public class DriverFeeService {

   //...

   public Money calculateDriverFee(Long transitId) {

        //...

        Integer finalFee;
        if (driverFee.getFeeType().equals(DriverFee.FeeType.FLAT)) {
           finalFee = transitPrice - driverFee.getAmount();
        } else {
           finalFee = transitPrice * driverFee.getAmount();
        }

        String currency = driverFee.getCurrency();
        //lub
        currency = AppConfiguration.DEFAULT_CURRENCY;

        //...

   }
}

Jest to żmudna, ale wykonywalna praca. Jednym z takich miejsc jest wspomniane już liczenie honorarium kierowcy.

Jeśli waluta rozliczeniowa ma najmniejszą jednostkę monetarną na poziomie jednej tysięcznej, czeka nas jeszcze wiele zmian w operacjach dokonywanych na kwotach. Przykładowo jest to liczenie kosztu przejazdu, o czym już wspominaliśmy:

public class Transit extends BaseEntity {

    private Integer calculateCost() {

        //...

        BigDecimal priceBigDecimal = new BigDecimal(km * kmRate * factorToCalculate + baseFee);
        if (currency.equals("PLN")) {
           priceBigDecimal = priceBigDecimal.setScale(2, RoundingMode.HALF_UP);
        } else if (currency.equals("BHD")) {
           priceBigDecimal = priceBigDecimal.setScale(3, RoundingMode.HALF_UP);
        } else if (...) {
         // ...
        } // ...
        int finalPrice = Integer.parseInt(String.valueOf(priceBigDecimal).replaceAll("\\.", ""));
        this.price = finalPrice;
        return this.price;
    }

    //...  
}

❓ Jaki problem rozwiązujemy?

Zmiennej reprezentacji pieniądza.

❗ A jaki problem wprowadzamy?

Kolejne instrukcje warunkowe zmniejszające czytelność kodu oraz potrzebę dodawania kolejnych pól, na przykład z walutą, do wszystkich miejsc, gdzie posługiwaliśmy się samym typem liczby całkowitej. Jeśli walut będzie sporo i przewidujemy kolejne zmiany – typ Integer zastąpimy innym, obsługującym wartości niecałkowite albo przewidujemy zmianę reprezentowania pieniądza jako napis lub też zdecydujemy się na bibliotekę, która modeluje to wszystko za nas – to prawdopodobnie będziemy musieli modyfikować te same miejsca raz jeszcze.

Przedstawiona w poprzednim module walidacja numerów praw jazdy, wykonywała się epizodycznie w niewielkiej liczbie miejsc w systemie. Nie spodziewamy się raczej aż tak częstego jej wprowadzania w innych miejscach aplikacji. Sytuacja z konceptem kwoty jest jednak inna.

Zmiany w wielu miejscach

Możemy założyć, że wraz z rozwojem systemu w wielu kolejnych miejscach przyjdzie nam posługiwać się tym właśnie modelem. W końcu pieniądz to rzecz występująca w prawie każdym biznesie.

Spójrz jeszcze raz na to rozwiązanie:

public class DriverFeeService {

   //...

   public Money calculateDriverFee(Long transitId) {

        //...

        Integer finalFee;
        if (driverFee.getFeeType().equals(DriverFee.FeeType.FLAT)) {
           finalFee = transitPrice - driverFee.getAmount();
        } else {
           finalFee = transitPrice * driverFee.getAmount();
        }

        String currency = driverFee.getCurrency();
        //lub
        currency = AppConfiguration.DEFAULT_CURRENCY;

        //...

   }
}

Zastanów się, czy warto stosować powyższe rozwiązanie, oparte o dodanie typu String w kolejnych miejscach systemu? Jaka sytuacja projektowa, organizacyjna, a być może nawet regulacyjna musiałaby wystąpić, żeby obronić tego typu pomysł?

Powyższe rozwiązanie ma sens w specyficznym kontekście i najpewniej jest to najszybsza ścieżka implementacji naszego wymagania. Być może jest to od dawna nierozwijany system, który po prostu musi być wdrożony w prawie identycznym stanie na inny rynek. Może aktualnie nad systemem pracuje zespół programistów, ale to z powodów marketingowych lub regulacyjnych mamy tylko jeden dzień, by wprowadzić zmianę na produkcję. W takich przypadkach jest to pewnie optymalne rozwiązanie. Świadomie zaciągamy tu jednak dług techniczny.

W naszym przypadku takich obostrzeń jednak nie mamy, pomyślmy więc o alternatywie.

Stabilny interfejs - koncept Money

Potrzebujemy stabilnego interfejsu, za którym ukryjemy zmienność konceptu pieniędzy. Coś stabilnego z zewnątrz, co może ukrywać swoją wewnętrzną zmienność. Dziś pieniądz jest Integerem, jutro Integerem z walutą, pojutrze czymś innym. Ta zmienna reprezentacja niech pozostanie jak najbardziej ukryta dla reszty systemu. To ukrycie, da nam swobodę zmiany. Ten stabilny interfejs może zapewnić nam klasa Money, która pozwala na dodawanie, liczenie procentów (oczywiście z odpowiednim zaokrągleniem) i wiele innych operacji.

public class Money {

   public static final Money ZERO = new Money(0);
   private Integer value;

   public Money(Integer value) {
       this.value = value;
   }

   public Money add(Money other) {
       return new Money(value + other.value);
   }

   public Money subtract(Money other) {
       return new Money(value - other.value);
   }

   //...
}

Wracamy do nadrzędnej zasady: klienty będą wiedzieć, CO (patrz: "CO?", a "JAK?") ma się wydarzyć – jakie jest obserwowalne zachowanie, a nie JAK to ma się wydarzyć – czyli przykładowo za pomocą Integera.

Takie rozwiązanie ma dodatkowy zysk. Brak zamknięcia konceptu za stabilnym interfejsem powodowałby, że w każdym z miejsc, gdzie używamy pieniądza, musielibyśmy dodawać nowy parametr, na przykład do wszystkich sygnatur metod:

public class DriverService {

   //...

   //przed
   public void chargeDriver(Long driverId, Integer amount) {
      //...
   }

   //po
   public void chargeDriver(Long driverId, Integer amount, String currency) {
      //...
   }

   //...
}

Nie jest to przyjemna praca.

❓ Jaki problem rozwiązaliśmy?

Zmiennej reprezentacji pieniądza.

Usuwalność kodu

Przypomnij lub wyobraź sobie sytuację podejmowania jakiejś decyzji modelarskiej, na przykład co do struktury danych, reprezentacji tych danych, kroków pewnego algorytmu. Być może w trakcie prac implementacyjnych przychodzi Ci do głowy zupełnie inny pomysł, jednak prace poszły już tak daleko, że ciężko jest to odwrócić. Wiele innych miejsc systemu dowiedziało się o Twoim pomyśle. Mimo że nowy jest lepszy, z żalem trzeba zostać z tym starym.

W kontekście refaktoryzacji niejednokrotnie w Legacy Fighter spotkasz się z wyłanianiem w kodzie stabilnego interfejsu, na którym oprzemy test po to, aby móc wykonywać swobodne zmiany w środku, "za granicą" tego interfejsu. Zmiany odbywające się za interfejsem będą zmianami lokalnymi, prostymi do wprowadzenia i jednocześnie nie będą wpływać na klientów naszego interfejsu. Tak właśnie było w przypadku refaktoryzacji walidacji numeru prawa jazdy. Tam interfejs był jasny, ale czasem należy go odkryć, bo nie jest on jawny w kodzie. Efekt jest ten sam, mamy możliwość swobodnych zmian w środku.

Taką samą technikę możemy, a wręcz powinniśmy stosować przy wyłanianiu nowego konceptu, nowego algorytmu, implementacji nowej struktury. Miewamy lepsze i gorsze pomysły, zmieniamy podjęte wcześniej decyzje, uzyskujemy nowe informacje, które na te decyzje mają wpływ. Nie chcemy więc utracić możliwości zmiany naszych decyzji.

Wyłanianie stabilnego interfejsu:

  • Zdefiniuj obserwowalne zachowania.
  • Nie ujawniaj szczegółów implementacyjnych.

Otoczmy naszą funkcję stabilnym interfejsem z obserwowalnymi zachowaniami, które nie udostępniają szczegółów implementacyjnych. W ten sposób zostawimy sobie przestrzeń na lepsze dni, kiedy do głowy wpadną nam lepsze pomysły. W skrajnym przypadku możemy naszą implementację wyrzucić i zastąpić czymś nowym.

Można nazwać to podatnością na "usuwanie". Usuwalność kodu to bardzo ważna cecha, która pozwala nam bezpiecznie rozwijać nasz system. Stabilny interfejs sprawia, że "stojący za nim" kod jest łatwy do usunięcia.

Dokładnie taka idea nam przyświeca, kiedy wprowadzamy klasę Money.

public class Money {

   public static final Money ZERO = new Money(0);
   private Integer value;

   public Money(Integer value) {
       this.value = value;
   }

   public Money add(Money other) {
       return new Money(value + other.value);
   }

   public Money subtract(Money other) {
       return new Money(value - other.value);
   }

   //...
}

Zyskujemy dzięki temu stabilny interfejs, a szczegóły implementacyjne będą na początku "jakieś". Być może nie najlepsze, być może nie najwydajniejsze. Ułatwimy sobie przez to jednak zmianę w przyszłości. Kod nie zawsze będzie piękny. Ważne, żeby brzydki kod był odseparowany i gotowy na poprawę.

Zapewnienie bezpieczeństwa zmiany

Sposób na zapewnienie bezpiecznego wprowadzenia konceptu Money przedstawiony jest na livecodingu: Lekcja 2.2.4. Zapewnienie bezpieczeństwa zmiany

Zmiana w repozytorium kodu:

git checkout vo-money-test

Wprowadzenie konceptu Money

Wprowadzenia konceptu Money przedstawione jest na livecodingu: Lekcja 2.2.5. Wprowadzenie nowego konceptu

Zmiana w repozytorium kodu:

git checkout vo-money

Podłączenie nowego kodu

Podłączenie klasy Money, przedstawione jest na livecodingu: Lekcja 2.2.6. Podłączenie nowego kodu

Zmiana w repozytorium kodu:

git checkout vo-money-podlaczenie

Kroki refaktoryzacji w postaci diagramu dostępne są w pliku PDF: Refaktoryzacja do wzorca Value Object — Money

Techniki: Extract Method / Move Method

Zastosowanie obu tych technik możesz zobaczyć w livecodingu: Lekcja 2.2.7. Techniki: Extract Method / Move Method

Kroki techniki Extract Method:

  • wyodrębnij logiczny fragment w rozbudowanej metodzie
  • nadaj mu intencję i wydziel do nowej metody o właściwej nazwie
  • wywołaj nową metodę, przekazując odpowiednie parametry

Kroki techniki Move Method:

  • przeanalizuj powiązania metody, np. stopień związania z klasą i jej właściwościami
  • przenieś metodę do wybranego, bardziej odpowiedniego komponentu
  • zaktualizuj wywołania metod w wyjściowym kodzie

Obserwacja efektu:

  • zwiększenie lokalnej czytelności kodu
  • zmniejszenie lokalnej złożoności kodu
  • zwiększenie kohezji klas

Obserwacja efektów

W trakcie tej refaktoryzacji typ Integer zamieniliśmy na koncept Money.

public class Money {

   private Integer value;

   public Money() {
   }

   public Money(Integer value) {
       this.value = value;
   }

   public Money add(Money other) {
       return new Money(value + other.value);
   }

   // ...
}

Integer stał się tu detalem implementacyjnym schowanym za stabilnym interfejsem. Brakuje jednak informacji o walucie, ale teraz, aby zmienić reprezentację pieniądza i uwzględnić w niej wspomnianą walutę, należy wykonać zmianę tylko w jednym miejscu.

public class Money {

   private Integer value;
   private String currency;

   public Money() {
   }

   public Money(Integer value, String currency) {
       this.value = value;
       this.currency = currency;
   }

   public Money add(Money other) {
       return new Money(value + other.value, this.currency);
   }

   // ...
}

Pojawia się tylko pytanie, czy dodawanie i odejmowanie kwot w różnych walutach ma sens. Ciągle jest to decyzja zamknięta za stabilnym interfejsem. Jedyną pracą będzie zamiana konstruktora na metodę wytwórczą, z domyślną walutą. To łatwa zmiana wspierana środowiskiem programistycznym.

Decyzja o najmniejszej jednostce monetarnej (na przykład 1 grosz) również znajduje się w tym jednym miejscu – możemy taką informację wstrzykiwać albo zaczynać od "ifologii".

public class Money {

   // ...

   @Override
   public String toString() {
       double value = Double.valueOf(this.value) / 100;
       return String.format(Locale.US, "%.2f", value);
   }
}

To decyzja lokalna, łatwa do ewentualnej zmiany w przyszłości.

Także zmiana na reprezentację za pomocą typu, który radzi sobie z kilkoma miejscami po przecinku, na przykład BigDecimal w Javie, odbyłaby się w jednym miejscu.

public class Money {

   public static final Money ZERO = new Money(BigDecimal.ZERO);
   private BigDecimal value;

   public Money() {
   }

   public Money(BigDecimal value) {
       this.value = value;
   }

   public Money add(Money other) {
       return new Money(value.add(other.value));
   }

   // ...
}

W systemach, które dokonują bardzo dużo obliczeń finansowych i gdzie porównywanie, dodawanie czy odejmowanie musi być bardzo wydajne, można oddzielnie zapisywać część ułamkową i całkowitą.

public class Money {

   public static final Money ZERO = new Money(0, 0);
   private Integer value;
   private Integer fraction;

   public Money() {
   }

   public Money(Integer value, Integer fraction) {
       this.value = value;
       this.fraction = fraction;
   }

   public Money add(Money other) {
       return new Money(value + other.value, fraction + other.fraction);
   }

   // ...
}

Znowu – będzie to decyzja lokalna. Podobnie jak decyzja o użyciu zewnętrznej biblioteki do pieniędzy.

Zauważ, że pewien zysk z inwestycji osiągamy już bez zmiany wewnętrznej struktury obiektu. Zamknięcie Integera za stabilnym interfejsem może być pierwszym krokiem refaktoryzacyjnym, który może bezpiecznie wejść na produkcję. Być może z czasem będziemy chcieli zmienić samą reprezentację, co wymaga odpowiednich zmian w kodzie oraz migracji w bazie danych. To byłby krok numer dwa.

Refaktoring dobrze podzielić na mniejsze, łatwiej wdrażalne kroki, które mogą szybko wylądować na produkcji.

Działanie małymi krokami

Może się okazać, że już pierwszy z nich jest wystarczających lub zyskamy nowe wnioski przed kolejnymi działaniami. Co więcej, zmiany dokonywane małymi krokami będą szybkie. Zminimalizujemy lub nawet unikniemy w ten sposób konfliktów podczas integracji.

Wydaje się, że ROI jest duże. W przyszłości zmiany będą zamknięte w jednym miejscu, co ułatwi rozwój systemu.

📈 Czy nie kusi Cię ten zwrot z inwestycji?

Jak to zrobić w moim projekcie?

W tym przypadku punktem wyjścia jest potrzeba zmiany sposobu, w jaki dana rzecz, a właściwie cecha tej rzeczy, jest reprezentowana w systemie. Przykładowo coś, co dziś jest opisane z wykorzystaniem 2 wartości Integer, jutro musi zostać wzbogacone dodatkową informacją typu String.

Kilka poniższych pytań, może pomóc Ci w podjęciu decyzję, czy warto przeprowadzić tego typu refaktoryzację:

  • Czy dana cecha zawsze wygląda tak samo?
  • Czy w najbliższym czasie będzie ona zmieniana?
  • Jak często ten kod będzie podlegał zmianom?
  • Ile miejsc należy zmienić?
  • Czy kilka różnych reprezentacji może funkcjonować równolegle w systemie?

Znając odpowiedzi na te pytania i widząc oczekiwany zwrot inwestycji, możesz działać.

Pierwszym krokiem działań jest oczywiście zbudowanie naszego bezpieczeństwa, poprzez wprowadzenie testu na odpowiedniej wysokości. Obserwowalne zachowania systemu powinny zostać niezmienne, ponieważ nie chcemy ryzykować przypadkowego błędu.

Kluczowym krokiem jest oczywiście zaproponowanie nowego obiektu. Powinien on ukrywać szczegóły związane z reprezentacją, a udostępniać na zewnątrz stabilny interfejs, całkowicie oderwany od tej reprezentacji. Interfejs ten możesz wyznaczyć, przeglądając wszystkie miejsca używające wyjściowej reprezentacji i zadając sobie np. pytania "jaki jest powód wykonania tych instrukcji?", czy "co ma być osiągnięte?". Aby upewnić się, czy znaleziony interfejs ma dobrą strukturę, zawsze możesz przeprowadzić proste ćwiczenie. Zasymuluj hipotetyczną, ale realną zmianę wymagań, która spowoduje potrzebę rozszerzenia reprezentacji obiektu o dodatkowe dane. Nie implementuj tego wymagania, zobacz tylko jego wpływ na twój obecny model. Jeśli zaproponowany interfejs okazał się przez to niewystarczający, dopracuj go.

Dalsze działania mogą wynikać z Generalnej Ścieżki Refaktoryzacji. Krok po kroku możesz przechodzić przez jej poszczególne etapy:

  • Zapewnij bezpieczeństwo zmiany poprzez wprowadzenie na odpowiednim poziomie testu opartego o odkryte obserwowalne zachowania.
  • Wprowadź nowy koncept do systemu, ze stabilnym interfejsem oderwanym od reprezentacji.
  • Podłącz nowy obiekt i upewnij się, że obserwowalne zachowania pozostały niezmienione.
  • Zaobserwuj osiągnięty efekt i podejmij decyzję odnośnie dalszych działań.

Być może jednak w trakcie nazywania problemu dojdziesz do wniosku, że to nie reprezentacja cech lub opis jakiejś rzeczy w systemie się zmienia, lecz coś zupełnie innego. Coś bogatszego w zachowania. Coś z określonym cyklem życia w systemie. Coś jednoznacznie identyfikowalnego... ale o tym gdzie indziej.

Użycie poszczególnych narzędzi, technik i wzorców dopasowujemy zawsze do konkretnej sytuacji projektu i jego problemów — zawsze ważny jest kontekst.

Dodatkowe komentarze

Na koniec zachęcamy Cię jeszcze do obejrzenia filmu: "Dodatkowe komentarze", w którym odpowiadamy na następujące pytania:

  • Dlaczego uruchomiono testy z niepoprawnymi wartościami?
  • Dlaczego pole DriverFee.amount nie zostało zrefaktoryzowane?
  • Dlaczego klasa Money zawiera jedynie pole typu Integer?
  • Dlaczego powstał test sprawdzający mapowanie do reprezentacji typem Integer?
  • Dlaczego zasada wspinacza została złamana?
  • Dlaczego kod testów został zrefaktoryzowany?

Podsumowanie

Warto zapamiętać:

  • dużo łatwiej zmienia się kod, jeśli otoczony jest stabilnym interfejsem

  • nawet jeśli nie jest on "czysty" i zmienny, to stabilny interfejs ukrywa to przed klientami

  • stabilny interfejs pozwala na łatwiejsze lokalne zmiany

  • "usuwalność" kodu to często jego ważna cecha

  • jest to po prostu kolejny driver architektoniczny, taki jak np. testowalność, czytelność czy skalowalność

  • przy refaktoryzcji (lub nawet przed) warto antycypować przyszłe (potencjalne i pewne) zmiany w projekcie

  • oraz zastanowić się, czy ich implementacja jest (lub byłaby) po zmianach łatwiejsza

Materiały: