金额计算的问题往往不在 BigDecimal 本身,而在币种、精度、舍入和比较规则散落各处。本文通过一个完整示例,将原始金额字段重构为不可变 Money 值对象。
问题背景:类型正确,不代表业务正确
Java 开发者通常知道金额不能使用 double,于是把字段统一换成 BigDecimal。但在真实项目里,下面这类代码依然很常见:
public BigDecimal calculatePayable(
BigDecimal unitPrice,
int quantity,
BigDecimal discountRate,
BigDecimal taxRate) {
BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
BigDecimal discount = subtotal.multiply(discountRate);
BigDecimal tax = subtotal.subtract(discount).multiply(taxRate);
return subtotal.subtract(discount)
.add(tax)
.setScale(2, RoundingMode.HALF_UP);
}它没有浮点误差,却留下了更多隐蔽问题:
unitPrice是否允许为负数或null?discountRate传入的是0.1,还是代表 10 的10?- 金额属于人民币还是美元?
- 折扣和税费在哪一步舍入?
- 两个不同币种的金额能否直接相加?
new BigDecimal("10.0").equals(new BigDecimal("10.00"))为什么是false?
如果这些规则散落在 Controller、Service 和定时任务里,使用 BigDecimal 只是保证了计算工具正确,并没有保证业务语义正确。
这是一种典型的“基本类型偏执”:程序用通用类型承载了特定领域概念,却没有把概念本身的约束表达出来。
核心思路:让 Money 成为真正的值对象
值对象没有独立身份,它由自身包含的值决定是否相等。一个可用的 Money 不应该只是给 BigDecimal 套一层外壳,而应承担几项明确职责:
- 金额与币种始终同时存在;
- 对象创建后不可变;
- 不允许不同币种直接运算;
- 数值相等不受
BigDecimal小数位数影响; - 舍入必须显式发生,不能藏在构造方法里。
下面的实现只依赖 JDK,可以直接放进普通 Java 项目:
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
public final class Money implements Comparable<Money> {
private final Currency currency;
private final BigDecimal amount;
private Money(Currency currency, BigDecimal amount) {
this.currency = Objects.requireNonNull(currency, "currency");
Objects.requireNonNull(amount, "amount");
this.amount = normalize(amount);
}
public static Money of(String currencyCode, BigDecimal amount) {
Objects.requireNonNull(currencyCode, "currencyCode");
return new Money(Currency.getInstance(currencyCode), amount);
}
public static Money zero(String currencyCode) {
return of(currencyCode, BigDecimal.ZERO);
}
public Money plus(Money other) {
requireSameCurrency(other);
return new Money(currency, amount.add(other.amount));
}
public Money minus(Money other) {
requireSameCurrency(other);
return new Money(currency, amount.subtract(other.amount));
}
public Money multiply(BigDecimal factor) {
Objects.requireNonNull(factor, "factor");
return new Money(currency, amount.multiply(factor));
}
public Money rounded(int scale, RoundingMode mode) {
return new Money(currency, amount.setScale(scale, mode));
}
public boolean isNegative() {
return amount.signum() < 0;
}
public BigDecimal amount() {
return amount;
}
public Currency currency() {
return currency;
}
@Override
public int compareTo(Money other) {
requireSameCurrency(other);
return amount.compareTo(other.amount);
}
private void requireSameCurrency(Money other) {
Objects.requireNonNull(other, "other");
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Currency mismatch: " + currency + " and " + other.currency);
}
}
private static BigDecimal normalize(BigDecimal value) {
return value.signum() == 0 ? BigDecimal.ZERO : value.stripTrailingZeros();
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof Money other)) {
return false;
}
return currency.equals(other.currency) && amount.equals(other.amount);
}
@Override
public int hashCode() {
return Objects.hash(currency, amount);
}
@Override
public String toString() {
return currency.getCurrencyCode() + " " + amount.toPlainString();
}
}这里特意没有在构造方法中统一执行 setScale(2)。并非所有币种都固定保留两位小数,而且计费中间值可能需要比最终结算值更高的精度。过早舍入会让多次计算产生累积偏差。
normalize 则解决了另一件事:BigDecimal.equals 同时比较数值和 scale。标准化后,10.0 与 10.00 会得到一致的内部形式,使 equals 和 hashCode 保持契约一致。不要只在 equals 中调用 compareTo,却继续使用原始 BigDecimal.hashCode(),否则放入 HashSet 或作为 HashMap 的键时会出现反直觉行为。
把舍入规则从金额对象中分离
金额负责安全运算,结算规则负责决定何时、如何舍入。可以把后者表达为一个小型策略对象:
import java.math.RoundingMode;
import java.util.Objects;
public record SettlementRule(int scale, RoundingMode mode) {
public SettlementRule {
if (scale < 0) {
throw new IllegalArgumentException("scale must not be negative");
}
Objects.requireNonNull(mode, "mode");
}
public Money settle(Money money) {
return money.rounded(scale, mode);
}
}这样做不是为了多创建一个类,而是为了避免不同服务各写一遍 setScale(2, HALF_UP)。当某个支付渠道采用不同精度时,差异也有明确的承载位置。
下面是一个接近完整的账单计算示例:
import java.math.BigDecimal;
import java.util.Currency;
import java.util.List;
import java.util.Objects;
public final class InvoiceCalculator {
public record LineItem(String name, Money unitPrice, int quantity) {
public LineItem {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(unitPrice, "unitPrice");
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
if (unitPrice.isNegative()) {
throw new IllegalArgumentException("unit price must not be negative");
}
}
public Money total() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
}
public record Invoice(
Money subtotal,
Money discount,
Money tax,
Money payable) {
}
private final SettlementRule settlementRule;
public InvoiceCalculator(SettlementRule settlementRule) {
this.settlementRule = Objects.requireNonNull(settlementRule);
}
public Invoice calculate(
List<LineItem> items,
Currency currency,
BigDecimal discountRate,
BigDecimal taxRate) {
Objects.requireNonNull(items, "items");
validateRate(discountRate, "discountRate");
validateRate(taxRate, "taxRate");
Money subtotal = items.stream()
.map(LineItem::total)
.reduce(Money.zero(currency.getCurrencyCode()), Money::plus);
Money discount = settlementRule.settle(
subtotal.multiply(discountRate));
Money afterDiscount = subtotal.minus(discount);
Money tax = settlementRule.settle(
afterDiscount.multiply(taxRate));
Money payable = settlementRule.settle(afterDiscount.plus(tax));
return new Invoice(subtotal, discount, tax, payable);
}
private static void validateRate(BigDecimal rate, String name) {
Objects.requireNonNull(rate, name);
if (rate.signum() < 0 || rate.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException(name + " must be between 0 and 1");
}
}
}调用方现在必须明确币种、比例含义和结算规则:
SettlementRule rule = new SettlementRule(2, RoundingMode.HALF_UP);
InvoiceCalculator calculator = new InvoiceCalculator(rule);
List<InvoiceCalculator.LineItem> items = List.of(
new InvoiceCalculator.LineItem(
"Java Book", Money.of("CNY", new BigDecimal("89.90")), 2),
new InvoiceCalculator.LineItem(
"Notebook", Money.of("CNY", new BigDecimal("12.50")), 3)
);
InvoiceCalculator.Invoice invoice = calculator.calculate(
items,
Currency.getInstance("CNY"),
new BigDecimal("0.10"),
new BigDecimal("0.06"));
System.out.println(invoice.payable());如果商品中误混入美元,汇总时会立即抛出异常,而不是安静地产生一个看似正常的数字。这正是值对象的价值:让错误尽量在靠近发生位置的地方暴露。
常见的重构陷阱
1. 一次性替换所有 BigDecimal
报表统计、数据库聚合结果和通用数学计算不一定都需要 Money。应该从订单应付、退款、账户余额等规则密集的边界开始,而不是机械地替换整个项目。
2. 把所有业务规则塞进 Money
“会员最多优惠 30%”属于促销规则,“退款不能超过实付金额”属于退款规则。Money 只维护金额自身的不变量,否则它会逐渐变成新的上帝类。
3. 在每次运算后自动舍入
自动舍入看似安全,实际会隐藏计算顺序。按商品行舍入后求和,与汇总后统一舍入可能得到不同结果。应根据财务或渠道规则明确舍入节点,并用测试固定下来。
4. 持久化时只保存数值
如果系统支持多币种,数据库至少需要同时保存金额与币种。读取时重新组装 Money,写入时拆分字段。不要依赖请求上下文猜测历史记录的币种。
5. 忽略序列化格式
stripTrailingZeros() 可能改变内部 scale,因此对外展示不要直接依赖内部形式。接口若要求固定两位小数,应在 DTO 映射层按协议格式化,而不是污染领域对象。
实践建议
重构前先收集现有规则:允许哪些币种、负数代表什么、比例范围是多少、在哪一步舍入。随后为边界情况补测试,至少覆盖零金额、不同 scale、币种不一致、非法比例以及舍入临界值。
迁移过程中可以保留旧接口,在入口处把 BigDecimal 和币种转换为 Money,再逐步让内部方法使用新类型。等调用链稳定后,再删除旧签名。相比一次性大改,这种方式更容易回滚,也更容易发现遗漏的隐式规则。
总结
金额代码难维护,通常不是因为 BigDecimal API 难用,而是因为币种、比较、舍入和合法性规则没有明确归属。Money 值对象通过不可变状态、同币种校验和行为封装,把散落的约定变成了可执行的边界。
好的重构并不追求类越多越好,而是让错误的写法更难出现,让关键规则只有一个可信实现。当方法参数从几个缺少语义的基础类型变成清晰的领域对象时,代码质量的提升也就不再只停留在命名层面。