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)
- Użycie
record: Idealny wybór dla niemutowalnego (immutable) obiektu wartości (Value Object) jakMoney. Zapewnia to bezpieczeństwo wątkowe i przewidywalność. - Użycie
BigDecimal: To jedyny słuszny wybór dla operacji finansowych w Javie, aby uniknąć błędów zaokrągleń znanych z typówfloatidouble. - Niemutowalność (Immutability): Metody
add,subtract,multiplynie modyfikują bieżącego obiektu, tylko zwracają nową instancjęMoney. To fundamentalna zasada dla obiektów wartości. - 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). - 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. - Walidacja w konstruktorze: Sprawdzanie, czy
amountnie jestnull, 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".