JavaのServiceが肥大化した時にクラスを分ける基準を解説する記事のアイキャッチ。Java道場風の学習空間で若手エンジニアが設計を考えるイラスト。

Java

Javaでクラスを分ける基準は?Serviceが肥大化した時の分割例

AIによる要約

クラスは行数だけで分けるのではなく、変更理由と責務で分けます。Serviceが入力変換、金額計算、在庫確認、保存、通知まで抱えたら、業務計算や外部連携を独立させる候補です。一方、処理を1行ずつ別クラスへ移すと流れが追いにくくなります。Serviceにはユースケースの進行とトランザクション境界を残し、独立して説明・テストできる責務を切り出します。

新人SE
新人SE
Serviceが300行を超えたので、メソッドごとにクラスを作ればよいですか?
ポンコツSE
ポンコツSE
行数は気づくきっかけにはなりますが、分割基準そのものではありません。同じ理由で変わる処理はまとめ、別の理由で変わる処理を切り出します。

新人が既存システムへ機能追加すると、Serviceクラスへ処理を足すことがよくあります。最初は登録だけだったServiceへ、割引計算、在庫確認、メール送信、監査ログが追加され、いつの間にか何でも担当するクラスになります。

そこで「クラスを分けてください」とレビューされても、何を単位に分ければよいかは教科書だけでは判断しづらいです。この記事では、業務システムの注文登録を例に、分割してよい責務とServiceへ残す責務を整理します。

この記事のポイント

  • 行数ではなく変更理由と責務でクラスを分ける
  • 計算、判定、外部連携は独立させやすい
  • Serviceにはユースケースの流れとトランザクション境界を残す
  • 分けた後の名前と入出力で責務を説明できるようにする

肥大化したServiceの例

次のServiceは、注文登録に必要な処理を1メソッドへ集めています。動作はしても、変更の影響範囲が広くなります。

@Transactional
public Long placeOrder(OrderRequest request) {
    Customer customer = customerRepository.findById(request.customerId())
            .orElseThrow(() -> new CustomerNotFoundException(request.customerId()));

    List<Product> products = productRepository.findAllById(request.productIds());

    int subtotal = products.stream()
            .mapToInt(Product::getPrice)
            .sum();
    int discount = customer.isPremium() ? subtotal / 10 : 0;
    int total = subtotal - discount;

    for (Product product : products) {
        if (product.getStock() <= 0) {
            throw new OutOfStockException(product.getId());
        }
        product.decreaseStock();
    }

    Order order = new Order(customer, products, total);
    orderRepository.save(order);

    mailClient.sendOrderAccepted(customer.getEmail(), order.getId());
    auditLogger.write("ORDER_CREATED", order.getId());
    return order.getId();
}

このメソッドには、顧客取得、商品取得、割引計算、在庫判定、在庫更新、注文生成、保存、メール、監査ログが含まれています。割引制度の変更とメール文面の変更は別の理由で発生しますが、同じクラスを修正することになります。

クラスを分ける4つの判断基準

1. 変更理由が違うか

価格制度は営業ルールで変わり、通知方法は運用ルールで変わります。変更を依頼する担当者や変更頻度が違うなら、別責務の可能性があります。

2. 単独で説明できる業務ルールか

「プレミアム会員は10%割引する」のように、処理の一部を1文で説明できるなら、計算クラスへ切り出しやすいです。逆に「注文を登録する」のようなユースケース全体はServiceが進行役として持つ方が自然です。

3. 入出力を小さく定義できるか

切り出したクラスがServiceのフィールドを大量に参照するなら、境界が曖昧です。必要な値を引数で渡し、結果を戻り値で返せる処理ほど独立させやすくなります。

4. 単独でテストする価値があるか

境界値が多い金額計算や状態遷移は、Repositoryを使わず単体テストできる形にすると効果があります。getterを1つ呼ぶだけの処理まで別クラスにしても、得られる価値は小さいです。

金額計算をクラスへ切り出す

割引計算は、DB保存やメール送信と独立した業務ルールです。入力と出力を明確にできます。

@Component
public class OrderPriceCalculator {

    public OrderPrice calculate(Customer customer, List<Product> products) {
        int subtotal = products.stream()
                .mapToInt(Product::getPrice)
                .sum();

        int discount = customer.isPremium() ? subtotal / 10 : 0;
        return new OrderPrice(subtotal, discount, subtotal - discount);
    }
}

計算結果をOrderPriceへまとめると、subtotal、discount、totalの対応を崩しにくくなります。Serviceへ3つのintをばらばらに返すより、意味のある値として扱えます。

外部通知を別クラスへ切り出す

メールやチャットなどの外部連携は、失敗時の再送、タイムアウト、文面変更といった独自の変更理由を持ちます。

@Component
public class OrderNotifier {
    private final MailClient mailClient;

    public OrderNotifier(MailClient mailClient) {
        this.mailClient = mailClient;
    }

    public void notifyAccepted(Order order) {
        mailClient.sendOrderAccepted(
                order.getCustomerEmail(),
                order.getId());
    }
}

ただし、通知失敗で注文全体をロールバックするかは要件次第です。トランザクション中に外部通信するか、コミット後にイベントで送るかは、勝手に変更せずチームへ確認します。

Serviceには処理の流れを残す

分割後のServiceは、ユースケースの順序とトランザクション境界を表します。

@Transactional
public Long placeOrder(OrderRequest request) {
    Customer customer = findCustomer(request.customerId());
    List<Product> products = productRepository.findAllById(request.productIds());

    products.forEach(Product::reserveOne);

    OrderPrice price = priceCalculator.calculate(customer, products);
    Order order = Order.create(customer, products, price);
    orderRepository.save(order);

    orderNotifier.notifyAccepted(order);
    return order.getId();
}

細部は別クラスへ移りましたが、注文登録がどの順序で進むかはServiceから読めます。これが「全部を別クラスへ移す」のではなく、進行役を残す考え方です。

トランザクション境界を壊さない

Serviceを分ける時は、@Transactionalの範囲を確認します。注文保存と在庫更新が同時に成功すべきなら、ユースケースを進行するServiceにトランザクションを置くのが分かりやすいです。

計算クラスへ@Transactionalを付けたり、細かいService同士が互いを呼び合ったりすると、どこまでが1処理なのか追いにくくなります。分割は、トランザクションを細切れにすることではありません。

分けすぎにも注意する

  • 1行の代入だけを行うクラス
  • 呼び出し元が1か所で、独自ルールもないラッパー
  • CommonUtilから名前だけ変えた用途不明クラス
  • データを運ぶだけなのに何層も変換を挟む構成

クラス数が多いこと自体が良い設計ではありません。ファイルを行き来しないと1つの業務処理が分からないなら、分け方を見直します。

分割後のクラス名を並べた時に、注文登録の構成要素として説明できるかを確認します。

現場レビューでの見られ方

// レビューコメント例
行数ではなく変更理由で見ると、割引計算と通知は別責務です。
PriceCalculatorとNotifierへの切り出しを検討してください。

// レビューコメント例
Serviceから処理の順序まで見えなくなっています。
ユースケースの進行とトランザクション境界はServiceへ残したいです。

// レビューコメント例
このクラスは1行を委譲するだけで独自ルールがありません。
分割によって読みやすくなっているか再確認してください。

レビュー前のセルフチェック

確認リスト

  • 別々の変更理由を1クラスが抱えていないか
  • 切り出す処理を業務用語で説明できるか
  • 切り出したクラスの入出力が必要以上に大きくないか
  • Serviceからユースケースの流れを読めるか
  • トランザクション境界を意図せず変えていないか
  • 分割によってファイル移動だけが増えていないか
書籍「改訂新版 良いコード/悪いコードで学ぶ設計入門」の表紙
商品画像:Amazon.co.jp
PR Java新人・若手向け

改訂新版 良いコード/悪いコードで学ぶ設計入門

仙塲 大也 (著)

レビューで直される理由を、Javaの具体例から理解する。

命名、責務、クラス設計、不変性などを良い例と悪い例で比較できます。現場のコードレビューと結びつけやすい一冊です。

  • 設計の悪さを具体例から学びたい
  • レビュー指摘の判断軸を増やしたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

クラス分割は、行数を減らす作業ではありません。変更理由、業務上の責務、入出力、単独テストの価値を見て、独立させる意味がある処理を切り出します。

Serviceにはユースケースの流れとトランザクション境界を残します。分割後のコードを読んだ人が、注文登録の順序と各クラスの役割を説明できる状態を目指してください。

-Java
-, , , ,