Skip to main content

Perform CR for the following Money class

import java.math.BigDecimal;
import java.math.RoundingMode;

public record Money(BigDecimal amount) {

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

public Money(BigDecimal amount) {
if (amount == null) {
throw new IllegalArgumentException();
}
this.amount = setScale(amount);
}

public Money add(Money money) {
return new Money(setScale(this.amount.add(money.amount())));
}

public Money subtract(Money money) {
return new Money(setScale(this.amount.subtract(money.amount())));
}

public boolean isGreaterThanZero() {
return this.amount != null && this.amount.compareTo(BigDecimal.ZERO) > 0;
}

public boolean isGreaterOrEqualThan(Money money) {
return this.amount != null && this.amount.compareTo(money.amount()) >= 0;
}

public Money multiply(int multiplier) {
return new Money(setScale(this.amount.multiply(new BigDecimal(multiplier))));
}

private BigDecimal setScale(BigDecimal input) {
return input.setScale(2, RoundingMode.HALF_EVEN);
}
}

Jasne, z przyjemnością wykonam szczegółowe code review. To bardzo dobrze napisany fragment kodu, który jest świetnym kandydatem do drobnych ulepszeń.

Główne pytanie: Czy ten kod poprawnie działa?

Tak, kod w obecnej formie będzie działał poprawnie. Logika arytmetyczna i porównawcza jest solidna. Użycie BigDecimal jest prawidłowe, a walidacja null w konstruktorze zapewnia podstawowe bezpieczeństwo. Normalizacja skali (setScale) jest kluczowa i została zaimplementowana poprawnie.

Poniżej znajdziesz szczegółową analizę, podzieloną na mocne strony, sugestie ulepszeń oraz przepisany kod.


Co działa bardzo dobrze (mocne strony)​

  1. Użycie record: Idealny wybór dla niemutowalnego (immutable) obiektu wartości (Value Object) jak Money. Zapewnia to bezpieczeństwo wątkowe i przewidywalność.
  2. Użycie BigDecimal: To jedyny słuszny wybór dla operacji finansowych w Javie, aby uniknąć błędów zaokrągleń znanych z typów float i double.
  3. Niemutowalność (Immutability): Metody add, subtract, multiply nie modyfikują bieżącego obiektu, tylko zwracają nową instancję Money. To fundamentalna zasada dla obiektów wartości.
  4. Normalizacja skali (setScale): Zapewnienie, że każda kwota ma taką samą skalę (2 miejsca po przecinku), jest kluczowe dla spójności i uniknięcia problemów przy porównaniach (equals).
  5. Tryb zaokrąglania RoundingMode.HALF_EVEN: To tzw. "zaokrąglenie bankierskie". Jest to najlepszy tryb dla większości operacji finansowych, ponieważ minimalizuje skumulowane błędy statystyczne przy dużej liczbie operacji. Bardzo duży plus za świadomy wybór.
  6. Walidacja w konstruktorze: Sprawdzanie, czy amount nie jest null, to podstawa budowania solidnych i niezawodnych obiektów.

Co można poprawić (sugestie)​

Chociaż kod działa, można go uczynić bardziej zwięzłym, czytelnym i zgodnym z nowoczesnymi idiomami Javy.

1. Użyj konstruktora kompaktowego (Compact Constructor)​

Rekordy oferują specjalną, skróconą składnię konstruktora, przeznaczoną właśnie do walidacji i normalizacji. Twój obecny kod z pełnym konstruktorem kanonicznym jest nieco rozwlekły.

Obecnie:

public Money(BigDecimal amount) {
if (amount == null) {
throw new IllegalArgumentException();
}
this.amount = setScale(amount);
}

Sugerowana zmiana (zwięźlej i czytelniej):

public Money { // To jest konstruktor kompaktowy
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null");
}
// Normalizacja parametru wejściowego
amount = setScale(amount);
}

W konstruktorze kompaktowym nie piszesz this.amount = .... Java zrobi to za Ciebie po zakończeniu bloku kodu. Przypisanie do amount modyfikuje parametr, który następnie zostanie użyty do zainicjowania finalnego pola.

2. Usuń redundantne sprawdzenia null​

W metodach isGreaterThanZero() i isGreaterOrEqualThan(Money money) sprawdzasz, czy this.amount nie jest null.

public boolean isGreaterThanZero() {
return this.amount != null && this.amount.compareTo(BigDecimal.ZERO) > 0;
}

To sprawdzenie jest zbędne. Dzięki walidacji w konstruktorze masz gwarancję, że jeśli obiekt Money istnieje, to jego pole amount nigdy nie będzie null. Usunięcie tego warunku uprości kod i lepiej odzwierciedli niezmienniki (invariants) Twojej klasy.

Sugerowana zmiana:

public boolean isGreaterThanZero() {
return this.amount.compareTo(BigDecimal.ZERO) > 0;
}

public boolean isGreaterOrEqualThan(Money money) {
return this.amount.compareTo(money.amount()) >= 0;
}

3. Użyj Objects.requireNonNull dla lepszych komunikatów​

Zamiast if (amount == null) throw new ..., standardową i bardziej wyrazistą praktyką jest użycie Objects.requireNonNull. Domyślnie rzuca NullPointerException, co jest często bardziej semantycznie poprawne, ale można też dostarczyć własny komunikat i rzucić IllegalArgumentException. Jednak w tym przypadku Objects.requireNonNull z komunikatem jest idealne.

Sugerowana zmiana:

import java.util.Objects;
// ... w konstruktorze kompaktowym
Objects.requireNonNull(amount, "Amount for Money cannot be null.");

4. Kwestia do przemyślenia: waluta (Currency)​

Twój obiekt Money reprezentuje kwotę, ale nie ma informacji o walucie. To sprawia, że operacje takie jak add(new Money(...)) są potencjalnie niebezpieczne. Dodanie 100 USD do 100 EUR nie powinno dać w wyniku 200. W bardziej zaawansowanych systemach Money często jest definiowane jako record Money(BigDecimal amount, Currency currency) i metody arytmetyczne sprawdzają, czy waluty są zgodne. Na razie to tylko sugestia na przyszłość, jeśli projekt będzie się rozwijał.


Przepisany kod (wersja po refaktoringu)​

Oto jak wyglądałby Twój kod po zastosowaniu powyższych sugestii.

package com.bigos.common.domain.vo;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

/**
* Reprezentuje niemutowalną wartość pieniężną.
* Gwarantuje, że kwota nigdy nie jest null i jest zawsze zaokrąglona
* do 2 miejsc po przecinku przy użyciu zaokrąglenia bankierskiego.
*/
public record Money(BigDecimal amount) {

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

/**
* Konstruktor kompaktowy do walidacji i normalizacji danych wejściowych.
*/
public Money {
// Używamy standardowej metody do sprawdzania nulla - jest bardziej zwięzła i idiomatyczna.
Objects.requireNonNull(amount, "Amount for Money cannot be null.");
// Normalizujemy parametr 'amount', który Java następnie przypisze do pola this.amount.
amount = amount.setScale(2, RoundingMode.HALF_EVEN);
}

// Ten konstruktor jest już niepotrzebny, ponieważ zastąpiliśmy go wersją kompaktową.
// public Money(BigDecimal amount) { ... }

public Money add(Money money) {
// Wynik dodawania jest przekazywany do konstruktora, który sam zadba o normalizację skali.
return new Money(this.amount.add(money.amount()));
}

public Money subtract(Money money) {
return new Money(this.amount.subtract(money.amount()));
}

/**
* Sprawdza, czy kwota jest większa od zera.
* Nie ma potrzeby sprawdzania `this.amount != null`, ponieważ konstruktor to gwarantuje.
*/
public boolean isGreaterThanZero() {
return this.amount.compareTo(BigDecimal.ZERO) > 0;
}

/**
* Sprawdza, czy kwota jest większa lub równa innej kwocie.
*/
public boolean isGreaterOrEqualThan(Money money) {
return this.amount.compareTo(money.amount()) >= 0;
}

public Money multiply(int multiplier) {
return new Money(this.amount.multiply(new BigDecimal(multiplier)));
}

// Ta metoda mogłaby pozostać, ale jej logika została przeniesiona bezpośrednio do konstruktora
// kompaktowego, co czyni ją zbędną, chyba że chcemy jej używać w wielu miejscach.
// Dla zwięzłości, przeniosłem jej logikę bezpośrednio.
// private BigDecimal setScale(BigDecimal input) { ... }
}

Podsumowanie​

Twój oryginalny kod działa poprawnie i jest dobrze przemyślany. Sugerowane zmiany nie naprawiają krytycznych błędów, ale podnoszą jakość kodu, czyniąc go:

  • Bardziej zwięzłym i czytelnym (konstruktor kompaktowy).
  • Bardziej solidnym i jednoznacznym (usunięcie zbędnych sprawdzeń null).
  • Bardziej zgodnym ze standardami (użycie Objects.requireNonNull).

To świetna robota! Wprowadzone zmiany to kolejny krok na drodze od "działającego kodu" do "czystego i eleganckiego kodu".