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


倉庫内の棚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に9 | 2個を移動 | Bに空きが足りないため拒否 | Aに8、Bに9 |
| Aに8、Bに9 | 1個を移動 | 移動できる | Aに7、Bに10 |
| Aに1、Bに0 | 2個を移動 | 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をデータベースへ別々に保存する場合は、保存の途中で失敗したときも結果を揃えるため、トランザクションの境界を別に決めます。同時実行で同じ在庫を更新する場合も、確認から反映までの競合を防ぐ設計が必要です。
例外の後に、両方の在庫と次の移動を確かめる
配布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版
Javaらしい設計と実装の判断基準を深める定番書。
equals、例外、ジェネリクス、ラムダ式などを、設計上の原則から学べます。初学者の一冊目ではなく、実務経験後の振り返り向けです。
- Javaコードの設計判断を深めたい
- レビュー指摘の背景まで理解したい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
在庫移動の途中で片方だけ代入すると、その後の例外では元に戻りません。移動元と移動先の条件を先に確認し、両方の新しい数を揃えてから反映します。テストには、例外が出ることと、その後も両方の在庫が残ることを書きます。



