荷物の移動がゲートで止まり、移動元の棚に箱が残っている倉庫のイラスト

設計・テスト・レビュー

Javaの在庫移動で片方だけ減る原因|検証してから反映する設計

この記事でわかること

在庫移動が失敗したのに、移動元だけ2個減るコードを再現します。移動元と移動先の条件を先に検証し、両方の新しい在庫数を揃えてから反映します。

新人SE
新人SE
棚Bに空きがなくて移動はエラーになりました。でも、棚Aの在庫だけ減っています。
ポンコツSE
ポンコツSE
例外を投げる前に、棚Aを書き換えていました。どちらの棚も変更してよいと分かってから、新しい在庫へ切り替えます。

倉庫内の棚Aから棚Bへ、同じ商品の在庫を移します。棚Aには8個、棚Bには9個あり、棚Bには10個まで置けます。2個移そうとした場合、棚Bに置けるのは1個だけなので、移動を拒否し、棚Aは8個、棚Bは9個を維持するのが仕様です。

この練習では、決まった二つの棚の在庫を、一つのJavaオブジェクトで扱います。対象は、クラスと例外は読めるものの、処理の途中で失敗した場合に何が残るかを設計したことがない人です。

実行サンプル

片方だけ減るコード、修正版、7件のJUnit、pom.xml、READMEを収録。JDK 17とMavenで、展開したディレクトリから mvn clean test を実行できます。

失敗なら両方据え置き、成功なら合計は同じ

呼び出し口はmove(int units)です。0個以下の移動を拒否し、移動元の在庫と移動先の空き容量を確認します。成功したら、一方で減った分と他方で増えた分が一致します。移動できなければ、どちらの在庫もその呼び出しの前と同じです。

開始時の在庫操作期待する結果終了時の在庫
Aに8、Bに92個を移動Bに空きが足りないため拒否Aに8、Bに9
Aに8、Bに91個を移動移動できるAに7、Bに10
Aに1、Bに02個を移動Aの在庫が足りないため拒否Aに1、Bに0

次がStockLevels.javaの全文です。在庫数二つを変更できないrecordで持ち、snapshot()を使って操作の前後を比較します。

package example.inventory;

record StockLevels(int source, int destination) {
    StockLevels {
        if (source < 0 || destination < 0) {
            throw new IllegalArgumentException("在庫数は0個以上です");
        }
    }
}

移動元を先に減らすと、例外の後もその減り方が残る

次は問題を再現するBrokenInventory.javaの全文です。unitsと移動元の在庫を確認してから、移動元だけを減らしたStockLevelsをフィールドへ代入しています。移動先の確認はその後です。

package example.inventory;

final class BrokenInventory {
    private final int destinationCapacity;
    private StockLevels levels;

    BrokenInventory(int source, int destination, int destinationCapacity) {
        if (destinationCapacity < destination) {
            throw new IllegalArgumentException("移動先の初期在庫が上限を超えています");
        }
        this.destinationCapacity = destinationCapacity;
        this.levels = new StockLevels(source, destination);
    }

    StockLevels snapshot() {
        return levels;
    }

    void move(int units) {
        if (units <= 0) {
            throw new IllegalArgumentException("移動数は1個以上です");
        }
        StockLevels before = levels;
        if (units > before.source()) {
            throw new IllegalStateException("移動元の在庫が足りません");
        }
        levels = new StockLevels(before.source() - units, before.destination());
        if (units > destinationCapacity - before.destination()) {
            throw new IllegalStateException("移動先に空きがありません");
        }
        levels = new StockLevels(levels.source(), before.destination() + units);
    }
}

Aに8個、Bに9個ある状態でmove(2)を呼ぶと、先にAが6個になります。その後、Bの空きが1個だと分かり、IllegalStateExceptionを投げます。例外が出ても、すでに行った代入は元に戻りません。snapshot()はAが6個、Bが9個を返し、在庫の合計は17個から15個に減っています。

両方を検証し、新しい在庫を最後に一度だけ反映する

修正版Inventory.javaでは、まず変更前の在庫をbeforeに取ります。移動元に必要な数があるか、移動先の空きに入るかをこの値から計算します。空きは上限から現在数を引いた値です。どれかの確認に失敗したら、その時点で終了します。

package example.inventory;

final class Inventory {
    private final int destinationCapacity;
    private StockLevels levels;

    Inventory(int source, int destination, int destinationCapacity) {
        if (destinationCapacity < destination) {
            throw new IllegalArgumentException("移動先の初期在庫が上限を超えています");
        }
        this.destinationCapacity = destinationCapacity;
        this.levels = new StockLevels(source, destination);
    }

    StockLevels snapshot() {
        return levels;
    }

    void move(int units) {
        if (units <= 0) {
            throw new IllegalArgumentException("移動数は1個以上です");
        }
        StockLevels before = levels;
        if (units > before.source()) {
            throw new IllegalStateException("移動元の在庫が足りません");
        }
        if (units > destinationCapacity - before.destination()) {
            throw new IllegalStateException("移動先に空きがありません");
        }

        StockLevels after = new StockLevels(before.source() - units, before.destination() + units);
        levels = after;
    }
}

両方の確認を通ったら、Aを減らしBを増やした新しいStockLevelsを作り、最後のlevels = afterで反映します。作りかけの在庫はフィールドに入らないので、Bに空きがない場合は、メソッドを呼んだ後もlevelsが元の8個と9個を持ちます。成功する1個なら、7個と10個に切り替わります。

このコードは、一つのスレッドから一つのInventoryを使う想定です。棚Aと棚Bをデータベースへ別々に保存する場合は、保存の途中で失敗したときも結果を揃えるため、トランザクションの境界を別に決めます。同時実行で同じ在庫を更新する場合も、確認から反映までの競合を防ぐ設計が必要です。

関連記事Spring Bootの@Transactionalはどこに付ける? 二つの値を別々のDB行に保存するとき、途中の失敗で片方の更新だけ残らないようにする設計です。 記事を読む

例外の後に、両方の在庫と次の移動を確かめる

配布ZIPを展開し、pom.xmlがある場所でmvn clean testを実行します。InventoryTest.javaの7件で、片方だけ減る旧版、成功時の合計、移動元や移動先の不足、0個と負数の拒否、失敗の後で数量を減らして呼び直す場合を確認できます。

InventoryTest.javaの全文を見る
package example.inventory;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class InventoryTest {
    @Test
    void observesThatTheBrokenVersionLosesStockOnFailure() {
        BrokenInventory inventory = new BrokenInventory(8, 9, 10);

        assertThrows(IllegalStateException.class, () -> inventory.move(2));

        assertEquals(new StockLevels(6, 9), inventory.snapshot());
    }

    @Test
    void preservesBothCountsWhenTheDestinationHasNoRoom() {
        Inventory inventory = new Inventory(8, 9, 10);
        StockLevels before = inventory.snapshot();

        assertThrows(IllegalStateException.class, () -> inventory.move(2));

        assertEquals(before, inventory.snapshot());
    }

    @Test
    void movesExactlyTheRemainingCapacityAndKeepsTheTotal() {
        Inventory inventory = new Inventory(8, 9, 10);
        StockLevels before = inventory.snapshot();

        inventory.move(1);

        StockLevels after = inventory.snapshot();
        assertEquals(new StockLevels(7, 10), after);
        assertEquals(before.source() + before.destination(), after.source() + after.destination());
    }

    @Test
    void preservesBothCountsWhenTheSourceIsShort() {
        Inventory inventory = new Inventory(1, 0, 10);

        assertThrows(IllegalStateException.class, () -> inventory.move(2));

        assertEquals(new StockLevels(1, 0), inventory.snapshot());
    }

    @ParameterizedTest
    @ValueSource(ints = {0, -1})
    void rejectsNonPositiveMovesWithoutChangingCounts(int units) {
        Inventory inventory = new Inventory(8, 9, 10);

        assertThrows(IllegalArgumentException.class, () -> inventory.move(units));

        assertEquals(new StockLevels(8, 9), inventory.snapshot());
    }

    @Test
    void canMoveOneUnitAfterRejectingTwo() {
        Inventory inventory = new Inventory(8, 9, 10);
        assertThrows(IllegalStateException.class, () -> inventory.move(2));

        inventory.move(1);

        assertEquals(new StockLevels(7, 10), inventory.snapshot());
    }
}
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>java-inventory-transfer-partial-update</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

移動先の空きが足りないケースでは、assertThrowsで例外を見た後に、assertEquals(before, inventory.snapshot())で二つの在庫をまとめて比較します。例外だけを検証していると、旧版のようにエラーと在庫減少が両方起きたケースも通ります。

修正版に試しに「Bの空きを確認する前にAだけ減らす代入」を追加すると、容量不足後に8と9が残るケースと、2個の移動に失敗してから1個を動かすケースの2件が失敗します。失敗後にもう一度操作できる業務では、次の操作が正しい在庫から始まることも確かめます。

レビューでは、最後に変わってよい状態を確認する

レビューでは「Bの空きが足りないときは、どちらの在庫も変わらない仕様です。Aを減らす代入を後ろへ移し、両方を検証してからまとめて反映できますか? 例外の後のAとBもテストに残したいです」と伝えます。

  • 同じ操作で一緒に変える値を挙げた。
  • 入力、変更元の残量、変更先の上限を代入前に確認した。
  • 成功時は差分と合計、失敗時は変更前の値を確認した。
  • 複数のDB行や外部サービスの変更がある場合は、別の失敗条件を確認した。

例外の後に状態を保つ設計を、さらに学ぶなら

『Effective Java 第3版』の項目76では、失敗した操作の後に、オブジェクトの状態をどう保つかを扱います。今回の例を動かした後、別のメソッドでも「例外が出たら変更前の値を残す」という約束をどこまで設けるか考えるときに役立ちます。

書籍「Effective Java 第3版」の表紙
商品画像:Amazon.co.jp
PR Java基礎を終えた若手向け

Effective Java 第3版

Joshua Bloch (著), 柴田 芳樹 (翻訳)

Javaらしい設計と実装の判断基準を深める定番書。

equals、例外、ジェネリクス、ラムダ式などを、設計上の原則から学べます。初学者の一冊目ではなく、実務経験後の振り返り向けです。

  • Javaコードの設計判断を深めたい
  • レビュー指摘の背景まで理解したい

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

関連記事Javaのクラス設計は何から学ぶ?最初の1冊と「どこに書くか」の考え方 値と処理をどのクラスへ置くか、料金と表示の例を動かしながら本を選べます。 記事を読む 関連記事テストケースの作り方|仕様・境界値・期待結果をJUnitと対応させる実例 失敗を拒否するだけでなく、状態が維持されることをどう期待値へ書くか確認できます。 記事を読む

まとめ

在庫移動の途中で片方だけ代入すると、その後の例外では元に戻りません。移動元と移動先の条件を先に確認し、両方の新しい数を揃えてから反映します。テストには、例外が出ることと、その後も両方の在庫が残ることを書きます。

-設計・テスト・レビュー
-, , ,