金额计算的问题往往不在 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 套一层外壳,而应承担几项明确职责:

  1. 金额与币种始终同时存在;
  2. 对象创建后不可变;
  3. 不允许不同币种直接运算;
  4. 数值相等不受 BigDecimal 小数位数影响;
  5. 舍入必须显式发生,不能藏在构造方法里。

下面的实现只依赖 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 值对象通过不可变状态、同币种校验和行为封装,把散落的约定变成了可执行的边界。

好的重构并不追求类越多越好,而是让错误的写法更难出现,让关键规则只有一个可信实现。当方法参数从几个缺少语义的基础类型变成清晰的领域对象时,代码质量的提升也就不再只停留在命名层面。

最后修改:2026 年 10 月 10 日
如果觉得我的文章对你有用,请随意赞赏