【Java】【DDD】割引に関する業務ルールの実装

本記事では支払い金額に対して割引を適用する架空の業務ルールを題材にして、業務ルールを型で表現する例を紹介します。

やりたいこと

支払い金額に対する割引適用に関する以下のような架空の業務ルールを型で表現する方法を考えます。

現実的ではない部分もありますが、以下の業務ルールを実装していきます。

  • 購入する商品金額を全て足し合わせた金額を小計金額と呼ぶ。小計金額には送料などの手数料は含まれない
  • 小計金額に対して割引を適用できる
  • 割引は1つの小計金額に対して複数適用できる
  • 割引は以下の2種類が存在する
    • 定額割引
      • 100円引きのように決まった金額を割引く
      • 定額割引は1つの支払に対して1つだけ適用できる
    • 定率割引
      • 10%引きのように決まった割合で割引く
      • 定率割引は1つの支払いに対して複数適用できる、例えば10%引きと20%引きを1つの支払いに適用できる
  • 割引は定率割引、定額割引の順に適用する
  • 割引適用後の金額がマイナスになる場合、0円とする

設計

小計金額、割引を表すインターフェイスやクラスを以下のように作成します。複数回割引を適用できる、一度だけ割引を適用できるということを表現するために、「割引を適用できる小計金額」と「割引を適用できない小計金額」を別の型にします。

「一度だけ適用できる割引」を小計金額へ適用すると「割引を適用できない小計金額」となります。

クラス名概要
SubtotalPrice小計金額全体を表すインターフェイス
ApplicableSubtotalPrice割引を適用できる小計金額を表すクラス
FinalSubtotalPrice割引を適用できない小計金額を表すクラス
Dicount割引全体を表すインターフェイス
ChainableDiscount小計金額に「複数適用できる割引」を表すインターフェイス
FinalizingDiscount小計金額に「一度だけ適用できる割引」を表すインターフェイス
PercentageDiscount「複数適用できる割引」の実装である定率割引を表すクラス
FixedAmountDiscount「一度だけ適用できる割引」の実装である定額割引を表すクラス
ChainableDiscountPriority複数回適用できる割引の優先順位を表す列挙型
DiscountedSubtotalPriceCalculator小計金額に割引を適用して割引後の小計金額を計算するクラス
作成するクラス

作成するクラス全体のクラス図は以下のようになります。

割引に関するクラスについて拡大したクラス図は以下のようになります。

小計金額に関するクラスについて拡大したクラス図は以下のようになります。

実装

これまで設計した内容をJavaで実装していきます。

小計金額を表すクラス

「割引を適用できる小計金額」、「割引を適用できない小計金額」を小計金額のサブタイプとして表現するため、小計金額をSubtotalPriceインターフェイスとして定義します。

import java.math.BigDecimal;

// 小計金額全体を表す
public interface SubtotalPrice {
    // 小計金額の値を返す
    BigDecimal value();
}

「割引を適用できる小計金額」をAppcableSubtotalPriceクラスとして定義します。

import java.math.BigDecimal;

// 割引を適用できる小計金額
public record ApplicableSubtotalPrice(BigDecimal value) implements SubtotalPrice {}

「割引を適用できない小計金額」をFinalSubtotalPriceクラスとして定義します。

import java.math.BigDecimal;

// 割引を適用できない小計金額
public record FinalSubtotalPrice(BigDecimal value) implements SubtotalPrice {}

割引を表すクラス

「複数適用できる割引」、「一度だけ適用できる割引」を割引のサブタイプとして表現するため、割引をDiscountインターフェイスとして定義します。このインターフェイスに割引を適用した後の小計金額を返すメソッドをapplyとして定義します。

このメソッドは小計金額を表すSubtotalPriceのサブタイプを引数で受け取り、SubtotalPriceのサブタイプを返します。

引数と返り値を型パラメータにしているのは「複数適用できる割引」は「割引を適用できる小計金額」を返し、「一度だけ適用できる割引」は「割引を適用できない小計金額」を返すことで、割引適用に関する業務ルールを表現するためです。

// 割引全体を表す
public interface Discount<I extends SubtotalPrice, R extends  SubtotalPrice> {
    // 割引適用メソッド
    R apply(I price);
}

「複数適用できる割引」をChainableDiscountインターフェイスとして定義します。本記事では「複数適用できる割引は定率割引のみですが、将来的に他の割引を追加できるように「複数適用できる割引」をインターフェイスとしています。

// 複数適用できる割引
// applyメソッドは「割引を適用できる小計金額」を受取り「割引を適用できる小計金額」を返す
public interface ChainableDiscount extends Discount<ApplicableSubtotalPrice, ApplicableSubtotalPrice> {
    ChainableDiscountPriority priority();
}

「一度だけ適用できる割引」はFinalizingDiscountインターフェイスとして定義します。

// 一度だけ適用できる割引
// applyメソッドは「割引を適用できる小計金額」を受取り「割引を適用できない小計金額」を返す
public interface FinalizingDiscount extends  Discount<ApplicableSubtotalPrice, FinalSubtotalPrice> {}

「複数適用できる割引」として10%引きのような定率割引を実装します。

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

// 定率割引
public class PercentageDiscount implements ChainableDiscount {
    private final BigDecimal percentage;
    private final ChainableDiscountPriority priority;

    public PercentageDiscount(BigDecimal percentage, ChainableDiscountPriority priority) {
        this.percentage = percentage;
        this.priority = priority;
    }

    @Override
    public ApplicableSubtotalPrice apply(ApplicableSubtotalPrice price) {
        var discountFactor = BigDecimal.ONE.subtract(percentage.divide(new BigDecimal("100"), 10, RoundingMode.HALF_UP));
        var discountedValue = price.value().multiply(discountFactor);
        return new ApplicableSubtotalPrice(discountedValue.setScale(2, RoundingMode.HALF_UP));
    }


    @Override
    public ChainableDiscountPriority priority() {
        return priority;
    }
}

「一度だけ適用できる割引」として100円引きのような定額割引を実装します。

import java.math.BigDecimal;

// 定額割引
public class FixedAmountDiscount implements FinalizingDiscount {

    private final BigDecimal discountAmount;

    public FixedAmountDiscount(BigDecimal discountAmount) {
        this.discountAmount = discountAmount;
    }

    @Override
    public FinalSubtotalPrice apply(ApplicableSubtotalPrice price) {
        BigDecimal discountedValue = price.value().subtract(discountAmount);

        // 割引適用後の金額がマイナスなった場合は0円とする
        if (discountedValue.compareTo(BigDecimal.ZERO) < 0) {
            return new FinalSubtotalPrice(BigDecimal.ZERO);
        }

        return new FinalSubtotalPrice(discountedValue);
    }
}

「複数適用できる割引」内での適用の優先順位を列挙型で作成します。本記事での例では「複数適用できる割引」は定率割引のみですので、この優先順位は意味はありません。これは将来的に定率割引以外の「複数適用できる割引」を追加することを想定しています。

import lombok.Getter;

// 「複数適用できる割引」内での適用の優先順位、数字が小さいほど優先順位が高い
@Getter
public enum ChainableDiscountPriority {

    CAMPAIGN(10),
    COUPON(20),
    REGULAR(30);

    private final int order;

    ChainableDiscountPriority(int order) {
        this.order = order;
    }
}

割引計算を行うクラス

割引適用前の小計金額と割引のリストを受け取り、全割引適用後の小計金額を返すクラスを以下のように作成します。

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

// 割引適用処理
public class DiscountedSubtotalPriceCalculator {

    public FinalSubtotalPrice calculate(ApplicableSubtotalPrice price, List<Discount<ApplicableSubtotalPrice, ? extends SubtotalPrice>> discounts) {

        // 「複数回適用できる割引」と「一度だけ適用できる割引」を別のリストに分割
        Map<Boolean, List<Discount<ApplicableSubtotalPrice, ? extends SubtotalPrice>>> discountsMap
                = discounts
                .stream()
                .collect(Collectors.partitioningBy(d -> d instanceof FinalizingDiscount));

        // 「一度だけ適用できる割引」のリスト、要素数は1のはず
        List<FinalizingDiscount> finalizingDiscounts = discountsMap.get(true)
                .stream()
                .map(d -> (FinalizingDiscount) d)
                .toList();

        // 「複数回適用できる割引」のリスト
        List<ChainableDiscount> chainableDiscounts = discountsMap.get(false)
                .stream()
                .map(d -> (ChainableDiscount) d)
                .toList();

        // 「一度だけ適用できる割引」が複数存在する場合はエラーとする
        if (finalizingDiscounts.size() > 1) {
            throw new IllegalArgumentException("Multiple finalizing discounts");
        }

        // 「複数回適用できる割引」を優先順位でソートする
        List<ChainableDiscount> sortedChainableDiscounts = chainableDiscounts.stream()
                .sorted(Comparator.comparing(d -> d.priority().getOrder()))
                .toList();

        // 「複数回適用できる割引」を適用する
        ApplicableSubtotalPrice discountedPrice = price;
        for (ChainableDiscount discount : sortedChainableDiscounts) {
            discountedPrice = discount.apply(discountedPrice);
        }

        // 「一度だけ適用できる割引」が存在する場合はその時点の小計金額を「割引を適用できない小計金額」にして返す
        if (!finalizingDiscounts.isEmpty()) {
            return finalizingDiscounts.getFirst().apply(discountedPrice);
        }

        // 「一度だけ適用できる割引」を適用し、適用後の小計金額を返す
        return new FinalSubtotalPrice(discountedPrice.value());
    }
}