💭

リクルート新人研修資料詳説 Part3 ~トランザクションスクリプトの長所・短所~

に公開

概要

この記事を読むと以下がわかります。

  • トランザクションスクリプトの長所・短所
  • トランザクションスクリプトの問題点とその原因

なお、この記事はリクルートの2025年度版新人研修資料を再構成し解説する連載記事のPart3です。
まず軽く下記の資料に目を通していただけると、より深く内容を理解できます。

今回解説する資料:
https://speakerdeck.com/recruitengineers/shi-jian-apurikesiyonshe-ji-toranzakusiyonsukuriputohenodui-ying

読了目安:30分

前回の記事:https://zenn.dev/izumi_ren/articles/3cdf7030892e97
次回の記事:

前回までのおさらい

モデルにはデータモデルドメインモデルがあります。

データモデルは単にデータだけを持つモデルで、
ドメインモデルはデータとその振る舞い(データを処理するメソッド)を持っています。

そして、各モデルで開発手法が異なります。

データモデルでは、データとその処理を別々に扱う「トランザクションスクリプト」が採用されます。

トランザクションスクリプトのイメージ

ドメインモデルでは、データとその処理を同じオブジェクトで扱い、そのオブジェクトの組み合わせで処理を実現する「ドメイン駆動設計」が採用されます。

ドメイン駆動設計のイメージ

トランザクションスクリプトの長所

フレームワークとの親和性

実はSpringを始めとするWebのフレームワークは、データモデルを前提とした仕様になっていることが多いです。これは、

  • ドキュメントや参考事例が豊富
  • メンバーを確保しやすい
    などのメリットがあります。

なんでフレームワークとの親和性が高い?

前項で「Webのフレームワークは、データモデルを前提とした仕様になっていることが多い」と書きましたが、現在メジャーな言語の多くはオブジェクト指向を言語仕様として取り入れており、データとその振る舞いを一緒にしたドメインモデルを実装することが可能です。なのになぜ、Webフレームワークはデータモデルを前提としているのでしょうか。言語の仕様と矛盾するような気がします。これには、歴史的な背景が関わっています。

かつて、業務アプリケーション開発はデータモデルを扱う手続き型の言語(COBOLやC)で書かれていました。そこにJavaが現れて徐々に広まっていくのですが、JavaがC言語からの移行を意識して作られていたことと、エンジニアが開発手法を急転換することの難しさが相まって、COBOLやC言語の開発方式がそのまま引き継がれました。Struts等のフレームワークではデータモデルが推奨さえされていたそうです。結果として、オブジェクト指向らしからぬデータモデルを扱うWebフレームワークが多くなっています。

参考(書籍中のコラム):https://www.amazon.co.jp/現場で役立つシステム設計の原則-変更を楽で安全にするオブジェクト指向の実践技法-増田-亨/dp/477419087X

ルールがシンプルでわかりやすい

トランザクションスクリプトは、業務処理毎に関数を用意するだけで完結します。設計に複雑な抽象化を必要としないため、開発の経験値やレベルが高くなくても取り組みやすいです。

  • 要件がシンプル
  • 小~中規模
  • スピードが重要で今後の変更予定がない
    といった性質を持つプロジェクトにはうってつけです

トランザクションスクリプトの短所

長所がある一方で、トランザクションスクリプトには無視できない短所があります。
それは、長期スパンで品質が悪化しやすいことです。開発初期は工数が抑えられますが、密結合で巨大な共通クラスが生まれやすいせいで可読性が落ちたり、拡張の際にデグレが起きたりします。
ではなぜそのような品質悪化が起こるのでしょうか。

トランザクションスクリプトの品質悪化原因

ロジックの重複による保守性の低下

例えば、どこかのデータモデルに単なる変数としてヒットポイントが用意されたとします。

int hitPoint;

ヒットポイントは増減するものです。どこかでダメージを受ける処理や回復する処理が必要です。

hitPoint = hitPoint - damageAmount;
if (hitPoint < 0 ) {
    hitPoint = 0;
}
hitPoint = hitPoint + recoveryAmount;
if (999 < hitPoint) {
    hitPoint = 999;
}

上記のようなヒットポイントの増減処理は、ゲーム内の様々な場面で必要になります。

  • 敵からの攻撃を受けたとき
  • 毒状態でターン終了したとき
  • 回復アイテムを使ったとき
  • 回復魔法を使ったとき
  • レベルアップした
    そして、データモデルを使う場合、これらの処理は各場面ごとで個別に実装されます。
// 敵から攻撃を受ける処理
public void receiveEnemyAttack(Character character, int damage) {
    character.hitPoint = character.hitPoint - damage;
    if (character.hitPoint < 0) {
        character.hitPoint = 0;
    }
    // その他の処理...
}

// 毒状態の処理
public void applyPoisonDamage(Character character) {
    int poisonDamage = 5;
    character.hitPoint = character.hitPoint - poisonDamage;
    if (character.hitPoint < 0) {
        character.hitPoint = 0;
    }
    // その他の処理...
}

// 回復アイテムを使う処理
public void useHealingItem(Character character, Item item) {
    character.hitPoint = character.hitPoint + item.healingAmount;
    if (999 < character.hitPoint) {
        character.hitPoint = 999;
    }
    // その他の処理...
}

// 回復魔法を使う処理
public void castHealingSpell(Character character, Spell spell) {
    character.hitPoint = character.hitPoint + spell.healingPower;
    if (999 < character.hitPoint) {
        character.hitPoint = 999;
    }
    // その他の処理...
}

こういった個別の実装が、即ちロジックの重複です。
hitPointを持つオブジェクトが自分のhitPointを管理するメソッドを持っていれば、皆そのメソッドを使うのでロジックは一つですが、
データモデルを使うと、hitPointを参照する各クラスで似たような内容の処理が書かれることになります。

ここで、「防具を装備している場合、ダメージを20%軽減する」という仕様が追加されたとします。
この場合、すべてのダメージ処理に防具チェックを追加する必要があります。

// 敵から攻撃を受ける処理に防具チェックを追加
public void receiveEnemyAttack(Character character, int damage) {
    int actualDamage = damage;
    if (character.hasArmor()) {
        actualDamage = (int)(damage * 0.8);
    }
    character.hitPoint = character.hitPoint - actualDamage;
    if (character.hitPoint < 0) {
        character.hitPoint = 0;
    }
}

// 毒状態の処理にも追加が必要
public void applyPoisonDamage(Character character) {
    int poisonDamage = 5;
    if (character.hasArmor()) {
        poisonDamage = (int)(poisonDamage * 0.8);
    }
    character.hitPoint = character.hitPoint - poisonDamage;
    if (character.hitPoint < 0) {
        character.hitPoint = 0;
    }
}

さらに、「防具の中でも"天女の羽衣"を装備している場合は回復量とダメージ軽減率が+20%される」という仕様が追加されたとしましょう。

// 敵から攻撃を受ける処理を再修正
public void receiveEnemyAttack(Character character, int damage) {
    int actualDamage = damage;
    if (character.hasArmor()) {
        double reduction = 0.8;  // 基本20%軽減
        if (character.getArmorName().equals("天女の羽衣")) {
            reduction = 0.6;  // 40%軽減(20% + 20%)
        }
        actualDamage = (int)(damage * reduction);
    }
    character.hitPoint = character.hitPoint - actualDamage;
    if (character.hitPoint < 0) {
        character.hitPoint = 0;
    }
}

// 回復アイテムを使う処理も再修正
public void useHealingItem(Character character, Item item) {
    int healingAmount = item.healingAmount;
    if (character.hasArmor() && character.getArmorName().equals("天女の羽衣")) {
        healingAmount = (int)(healingAmount * 1.2);  // 回復量+20%
    }
    character.hitPoint = character.hitPoint + healingAmount;
    if (999 < character.hitPoint) {
        character.hitPoint = 999;
    }
}

// 回復魔法を使う処理も再修正
public void castHealingSpell(Character character, Spell spell) {
    int healingAmount = spell.healingPower;
    if (character.hasArmor() && character.getArmorName().equals("天女の羽衣")) {
        healingAmount = (int)(healingAmount * 1.2);  // 回復量+20%
    }
    character.hitPoint = character.hitPoint + healingAmount;
    if (999 < character.hitPoint) {
        character.hitPoint = 999;
    }
}

// 毒状態の処理も再修正(またしても修正が必要!)
public void applyPoisonDamage(Character character) {
    int poisonDamage = 5;
    if (character.hasArmor()) {
        double reduction = 0.8;
        if (character.getArmorName().equals("天女の羽衣")) {
            reduction = 0.6;
        }
        poisonDamage = (int)(poisonDamage * reduction);
    }
    character.hitPoint = character.hitPoint - poisonDamage;
    if (character.hitPoint < 0) {
        character.hitPoint = 0;
    }
}

このように、仕様が追加されるたびに、すべての関連箇所を修正する必要があります。
しかも、

  • 修正漏れが発生しやすい(毒ダメージには防具軽減を入れ忘れた、など)
  • 同じロジックを何度も書く必要がある
  • テストすべき箇所が増え続ける
    といった問題が発生します。

さらに深刻なのは、異なる開発者が同じようなロジックを少しずつ違う形で実装してしまうことです。

// 開発者Aが書いたコード
character.hitPoint = character.hitPoint - damage;
if (character.hitPoint < 0) {
    character.hitPoint = 0;
}

// 開発者Bが書いたコード
character.hitPoint = Math.max(0, character.hitPoint - damage);

// 開発者Cが書いたコード
character.hitPoint -= damage;
if (character.hitPoint <= 0) {
    character.hitPoint = 0;
    character.isDead = true;
}

同じ目的のコードなのに、書き方がバラバラです。

  • どれが正しい実装なのか判断できない
  • バグが混入していても気づきにくい
  • レビューやメンテナンスのコストが増大する
    といった問題が生じます。

総じて、トランザクションスクリプトはロジックの重複による保守性の低下を招きやすいです。

間違った共通化

前項のような事態を避けるために、様々なクラスで使う処理を共通化したUtilクラスやServiceを作ることは一つの有効な手段です。
しかしながら、これも慎重に行わなければ保守性の低下を招きます。

例えば、以下のような2つの処理があったとします:

// 注文時の割引計算
public class OrderService {
    public int calculateOrderDiscount(Order order) {
        if (order.getAmount() >= 10000) {
            return (int)(order.getAmount() * 0.1);  // 10%割引
        }
        return 0;
    }
}

// キャンペーン時の割引計算
public class CampaignService {
    public int calculateCampaignDiscount(Order order) {
        if (order.getAmount() >= 10000) {
            return (int)(order.getAmount() * 0.1);  // 10%割引
        }
        return 0;
    }
}

コードが重複しているので、共通化したくなります。

public class DiscountUtil {
    public static int calculateDiscount(int amount) {
        if (amount >= 10000) {
            return (int)(amount * 0.1);
        }
        return 0;
    }
}

public class OrderService {
    public int calculateOrderDiscount(Order order) {
        return DiscountUtil.calculateDiscount(order.getAmount());
    }
}

public class CampaignService {
    public int calculateCampaignDiscount(Order order) {
        return DiscountUtil.calculateDiscount(order.getAmount());
    }
}

しかし、これは概念的には別物です。仕様変更が来たときに問題が顕在化します。

仕様変更:通常の注文割引は15,000円以上で15%割引に変更

// DiscountUtilを変更すると...
public class DiscountUtil {
    public static int calculateDiscount(int amount) {
        if (amount >= 15000) {
            return (int)(amount * 0.15);  // 変更
        }
        return 0;
    }
}

この変更で、キャンペーン割引の計算も変わってしまいます。本来、キャンペーン割引は独立した仕様であり、通常割引の変更に影響されるべきではありません。

結局、以下のような修正が必要になります:

// 無理やりパラメータを追加
public class DiscountUtil {
    public static int calculateDiscount(int amount, boolean isCampaign) {
        if (isCampaign) {
            if (amount >= 10000) {
                return (int)(amount * 0.1);
            }
        } else {
            if (amount >= 15000) {
                return (int)(amount * 0.15);
            }
        }
        return 0;
    }
}

これでは共通化の意味がありません。むしろ

  • 条件分岐が増えて複雑化
  • テストケースが倍増
  • 「通常割引」と「キャンペーン割引」の仕様が1つのメソッドに混在
    という問題が発生しています。

ここでさらに仕様が追加されると、共通化したUtilクラスは破綻します。

追加仕様:

  • 通常割引:20,000円以上で送料無料
  • キャンペーン割引:期間限定で5,000円以上から適用
  • 会員ランク別割引:ゴールド会員は常に20%割引
// どんどん肥大化するDiscountUtil
public class DiscountUtil {
    public static int calculateDiscount(int amount, boolean isCampaign, 
                                       String memberRank, boolean isFreshipping,
                                       LocalDate campaignStartDate, LocalDate campaignEndDate) {
        // if文地獄
        if (isCampaign) {
            if (LocalDate.now().isAfter(campaignStartDate) 
                && LocalDate.now().isBefore(campaignEndDate)) {
                if (amount >= 5000) {
                    return (int)(amount * 0.1);
                }
            }
        } else {
            if ("GOLD".equals(memberRank)) {
                return (int)(amount * 0.2);
            }
            if (amount >= 15000) {
                return (int)(amount * 0.15);
            }
        }
        return 0;
    }
}

このように

  • メソッドのパラメータが増え続ける
  • 条件分岐が複雑化し、可読性が低下
  • 異なる概念(通常割引、キャンペーン割引、会員割引)が1つのメソッドに混在
  • テストが困難(すべての組み合わせをテストする必要がある)
    といった、事態を招きやすいのがUtilクラス含めた共通処理です。

神クラス

トランザクションスクリプトでは、ビジネスロジックをServiceクラスに集約する設計が一般的です。しかし、このServiceクラスが肥大化し、あらゆる責務を抱え込む「神クラス(God Class)」になりがちです。

例えば、ECサイトの注文処理を考えてみましょう。最初はシンプルなOrderServiceからスタートします。

@Service
public class OrderService {
    
    public void createOrder(OrderRequest request) {
        // 注文を作成する
        Order order = new Order();
        order.setUserId(request.getUserId());
        order.setItems(request.getItems());
        order.setTotalAmount(calculateTotal(request.getItems()));
        orderRepository.save(order);
    }
    
    private int calculateTotal(List<OrderItem> items) {
        return items.stream()
            .mapToInt(item -> item.getPrice() * item.getQuantity())
            .sum();
    }
}

しかし、プロジェクトが進むにつれて、様々な要件が追加されていきます。
在庫チェック、ポイント計算、クーポン適用などなど…

@Service
public class OrderService {
    
    public void createOrder(OrderRequest request) {
        // 在庫チェック
        for (OrderItem item : request.getItems()) {
            int stock = inventoryRepository.findById(item.getProductId()).getStock();
            if (stock < item.getQuantity()) {
                throw new OutOfStockException("商品ID:" + item.getProductId());
            }
        }
        
        // 注文を作成
        Order order = new Order();
        order.setUserId(request.getUserId());
        order.setItems(request.getItems());
        int totalAmount = calculateTotal(request.getItems());
        
        // クーポン適用
        if (request.getCouponCode() != null) {
            Coupon coupon = couponRepository.findByCode(request.getCouponCode());
            if (coupon != null && isValidCoupon(coupon, totalAmount)) {
                int discount = calculateDiscount(totalAmount, coupon);
                totalAmount = totalAmount - discount;
                order.setDiscountAmount(discount);
                order.setCouponCode(request.getCouponCode());
                coupon.setUsed(true);
                couponRepository.save(coupon);
            }
        }
        
        order.setTotalAmount(totalAmount);
        orderRepository.save(order);
        
        // 在庫を減らす
        for (OrderItem item : request.getItems()) {
            Inventory inventory = inventoryRepository.findById(item.getProductId());
            inventory.setStock(inventory.getStock() - item.getQuantity());
            inventoryRepository.save(inventory);
        }
        
        // ポイント計算・付与
        User user = userRepository.findById(request.getUserId());
        int earnedPoints = calculatePoints(totalAmount, user.getMemberRank());
        user.setPoints(user.getPoints() + earnedPoints);
        userRepository.save(user);
        
        // メール通知
        String emailBody = buildOrderConfirmationEmail(order, user);
        emailService.send(user.getEmail(), "ご注文ありがとうございます", emailBody);
        
        // 管理者にも通知
        if (totalAmount > 100000) {
            String adminEmailBody = buildAdminNotificationEmail(order, user);
            emailService.send("admin@example.com", "高額注文が発生しました", adminEmailBody);
        }
    }
    
    private int calculateTotal(List<OrderItem> items) { /* ... */ }
    private int calculatePoints(int amount, String memberRank) { /* ... */ }
    private boolean isValidCoupon(Coupon coupon, int amount) { /* ... */ }
    private int calculateDiscount(int amount, Coupon coupon) { /* ... */ }
    
    private String buildOrderConfirmationEmail(Order order, User user) {
        StringBuilder sb = new StringBuilder();
        sb.append(user.getName()).append(" 様\n\n");
        sb.append("ご注文ありがとうございます。\n");
        sb.append("注文番号: ").append(order.getId()).append("\n");
        sb.append("合計金額: ").append(order.getTotalAmount()).append("円\n");
        // ... 長いメール本文構築処理
        return sb.toString();
    }
    
    private String buildAdminNotificationEmail(Order order, User user) {
        StringBuilder sb = new StringBuilder();
        sb.append("高額注文が発生しました。\n");
        sb.append("注文番号: ").append(order.getId()).append("\n");
        sb.append("顧客: ").append(user.getName()).append("\n");
        sb.append("金額: ").append(order.getTotalAmount()).append("円\n");
        // ... 長いメール本文構築処理
        return sb.toString();
    }
}

このように、機能追加のたびにOrderServiceは肥大化していきます。最終的には

  • 100行以上のメソッド
  • 10個以上のprivateヘルパーメソッド
  • 複数のRepositoryへの依存
  • 在庫管理、ポイント計算、クーポン処理、メール送信など、まったく異なる責務を持つ

という巨大な神クラスが誕生します。

こういった神クラスの問題点はいくつもあります。

  1. 可読性の低下

    • メソッドが長すぎて何をしているのか理解しにくい
    • 処理の流れを追うのに時間がかかる
  2. テストの困難性

    • 1つのメソッドで多くのことをしているため、テストケースが膨大
    • モックすべき依存関係が多すぎる
  3. 変更の影響範囲が不明確

    • ポイント計算ロジックを変更したいだけなのに、注文処理全体を理解する必要がある
    • 小さな変更でも既存機能に影響が出る可能性がある
  4. 並行開発の困難性

    • 複数の開発者が同じServiceクラスを修正すると、コンフリクトが頻発
    • レビューも大変(1つのクラスに様々な変更が混在)
  5. 再利用性の欠如

    • ポイント計算ロジックを別の場所で使いたくても、OrderServiceに密結合している
    • クーポン処理を他の機能でも使いたいが、切り出すのが困難

もちろん、トランザクションスクリプトでも丁寧に書けば神クラスを避けることは可能です。
しかし、トランザクションスクリプトには、構造的に神クラスを生み出しやすい性質があります。

なぜトランザクションスクリプトは神クラスを生みやすいのか

1. ロジックを置く場所が限られている

トランザクションスクリプトでは、データモデル(Entity)は単なるデータの入れ物です。ビジネスロジックを書く場所がないため、必然的にServiceクラスに集約されます。

// Entity: ビジネスロジックを持てない
public class Order {
    private Long id;
    private int totalAmount;
    // getterとsetterのみ
}

// Service: ビジネスロジックがすべてここに集まる
@Service
public class OrderService {
    // 注文に関するすべてのロジックがここに集約される
    public void createOrder() { /* ... */ }
    public void cancelOrder() { /* ... */ }
    public void refundOrder() { /* ... */ }
    public void calculateTotal() { /* ... */ }
    public void applyDiscount() { /* ... */ }
    public void validateOrder() { /* ... */ }
    // ... 際限なく増えていく
}

一方、ドメインモデルでは、各Entityが自分に関連するロジックを持てるため、自然とロジックが分散されます。

// Entity: 自分に関するロジックを持つ
public class Order {
    private Long id;
    private int totalAmount;
    
    // 注文自身に関するロジックはここに書ける
    public void applyDiscount(Coupon coupon) { /* ... */ }
    public boolean isValid() { /* ... */ }
    public int calculateTotal() { /* ... */ }
}

// Service: オーケストレーションのみ
@Service
public class OrderService {
    public void createOrder(OrderRequest request) {
        Order order = Order.create(request);  // Entityが処理を担う
        order.applyDiscount(request.getCoupon());
        orderRepository.save(order);
    }
}

2. 関連するロジックを一箇所にまとめたくなる心理

注文について取り扱うOrderServiceが有れば、自然と「注文に関する処理はOrderServiceに書こう」とと思ってしまいます。
しかし、これが罠となります。

例えば、在庫管理のロジックを考えてみましょう。

  • 「注文時に在庫を減らす」→ 注文処理だからOrderServiceに書く
  • 「在庫が少なくなったら警告」→ これも注文に関係するからOrderServiceに書く?
  • 「在庫の補充」→ これは注文とは関係ないけど、すでに在庫関連のコードがOrderServiceにあるから…

こうして「注文処理」のつもりが、気づけば在庫管理、ポイント管理、メール送信など、あらゆる責務を抱え込んでしまいます。

@Service
public class OrderService {
    // 注文処理のはずが...
    
    // 在庫管理ロジック
    private void checkStock() { /* ... */ }
    private void reduceStock() { /* ... */ }
    private void checkLowStockWarning() { /* ... */ }
    
    // ポイント管理ロジック
    private void calculatePoints() { /* ... */ }
    private void grantPoints() { /* ... */ }
    private void checkPointExpiry() { /* ... */ }
    
    // クーポン管理ロジック
    private void validateCoupon() { /* ... */ }
    private void applyCoupon() { /* ... */ }
    private void markCouponAsUsed() { /* ... */ }
    
    // メール送信ロジック
    private void sendOrderConfirmation() { /* ... */ }
    private void sendAdminNotification() { /* ... */ }
    
    // 本来の注文処理
    public void createOrder() { /* ... */ }
}

3. 既存のServiceを拡張する方が楽

新しいServiceクラスを作るには

  • クラス名
  • DIの設定
  • どのServiceに何を置くかという設計
    など考えることが多いです。

一方、既存のServiceに追加するだけなら

  • privateメソッドを1つ追加
  • すでに必要なRepositoryがDI済み
  • 設計を考えなくていい
    と、楽ちんです。

結果として、安易に既存のServiceクラスにコードを追加してしまいます。

// 楽な選択:既存のOrderServiceに追加
@Service
public class OrderService {
    // すでにこれらがDIされている
    @Autowired private OrderRepository orderRepository;
    @Autowired private UserRepository userRepository;
    @Autowired private InventoryRepository inventoryRepository;
    
    // 新機能:ギフトラッピング対応
    // → わざわざ新しいServiceを作らず、ここに追加してしまう
    public void addGiftWrapping(Long orderId, GiftWrappingRequest request) {
        Order order = orderRepository.findById(orderId);
        // ... ギフトラッピングの処理
    }
}

// 大変な選択:新しいServiceを作る
@Service
public class GiftWrappingService {
    // DIの設定が必要
    @Autowired private OrderRepository orderRepository;  // OrderServiceと重複
    @Autowired private GiftWrappingRepository giftWrappingRepository;
    
    public void addGiftWrapping(Long orderId, GiftWrappingRequest request) {
        // OrderServiceとの責務の境界を考える必要がある
    }
}

これらの構造的な問題により、トランザクションスクリプトでは意識的に設計しないと神クラスが生まれやすいです。

対してドメインモデルでは、データとロジックが一体化しているため自然と責務が分散されます。各ドメインオブジェクトが自分の責務を持つため、Serviceクラスに集中することがありません。

まとめ

小規模で変更予定がないプロジェクトならトランザクションスクリプトでも問題ない。
長期的な保守・拡張を見据えると何かと問題点が多い。

次回の記事では、ドメイン駆動設計の解説をしようと思います。

最後まで読んでいただきありがとうございました。

次の記事:

Discussion