Featured image of post コード探偵ロックの事件簿【Aggregate】帳簿崩壊の怪〜バラバラの注文が招く金額の亡霊〜

コード探偵ロックの事件簿【Aggregate】帳簿崩壊の怪〜バラバラの注文が招く金額の亡霊〜

注文と注文明細がバラバラに操作され金額不整合が頻発する問題を、Aggregate RootのOrderが整合性の門番となり子要素への操作を一元管理するリファクタリング事例をPerl/Mooで解説

名刺を一枚、握りしめていた。

「レガシー・コード・インベスティゲーション」。先輩が退職する前日に、デスクの引き出しから取り出して渡してくれたものだ。「いいか、本当にヤバくなったらここに行け」。それだけ言って、先輩は荷物をまとめて会社を出ていった。

半年経った。本当にヤバくなった。

雑居ビルの前で立ち止まる。三階の窓に小さな看板が見える。「LCI」。探偵事務所か何かだろうか。エレベーターはない。階段を上がるしかない。

三階のドアの前で一瞬ためらった。先輩は「行けばわかる」としか言わなかった。何がわかるのか、さっぱりわからないまま、ノックした。

「開いているよ」

ドアを押すと、デスクの排熱とエナジードリンクの甘い匂いが混ざった空気が流れてきた。デスクトップPCが2台。飲みかけの缶が3本。そして——デスクの端に、小さな天秤が置いてあった。

男がその天秤の前に座っていた。左の皿にエナジードリンクの缶、右の皿に小さなメモ帳。缶を載せたり下ろしたりしている。

「あの……LCIの方ですか? 先輩に名刺をもらって——」

「D社の新作エナジードリンク。比重1.04。成分表の糖質量から逆算すると1.038で、誤差0.2%。製造ロットの個体差の範囲内だね」

何を言っているんだ、この人は。

男がようやく顔を上げた。僕の手にある名刺を一瞥して、口元がわずかに動いた。

「ああ、カワモトの後輩か。彼が名刺を渡すのは、相当な重症のときだけだ」

先輩の名前を知っている。それだけで少し安心した。

「ロック。コードの探偵だ。——さて、見せてくれたまえ、ワトソン君」

「あの、僕の名前は——」

「コードだよ、コード。名前は後でいい」

ロックさんはPCの前に移動し、椅子をもう一脚引っ張ってきた。

(……先輩、あなたが何も説明しなかった理由がわかった気がする)

僕はノートPCを取り出し、注文管理システムのコードを画面に映した。

事件現場——バラバラの注文

「ECサイトの注文管理システムです。注文と注文明細のクラスがあって——問題は、月末の経理確認で合計金額と明細の合計が合わないことがあるんです」

僕はまず OrderItem クラスを見せた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package OrderItem;
use Moo;
use v5.36;
use Types::Standard qw(Str Num Int);

has id           => (is => 'ro',  isa => Str, required => 1);
has product_name => (is => 'ro',  isa => Str, required => 1);
has unit_price   => (is => 'rw',  isa => Num, required => 1);
has quantity     => (is => 'rw',  isa => Int, required => 1);

sub subtotal ($self) {
    return $self->unit_price * $self->quantity;
}

1;

そして Order クラス。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package Order;
use Moo;
use v5.36;
use Types::Standard qw(Str ArrayRef InstanceOf);

has id    => (is => 'ro', isa => Str, required => 1);
has items => (
    is      => 'ro',
    isa     => ArrayRef[InstanceOf['OrderItem']],
    default => sub { [] },
);

sub total_amount ($self) {
    my $total = 0;
    $total += $_->subtotal for $self->items->@*;
    return $total;
}

1;

「3ヶ月前から、月末のたびに経理から連絡が来ます。『注文番号○○の合計金額が明細と合いません』。そのたびにSQLで合計金額を手動修正しました。3回。3回とも原因が違う場所で、3回とも同じ種類の不整合でした」

ロックさんはコードを上から下まで黙読していた。長い沈黙。

容疑者の浮上——帳簿係のいない帳簿

「……ワトソン君、帳簿というものを知っているかね」

また「ワトソン君」だ。もう訂正する気力はない。それより帳簿という言葉が引っかかった。

「帳簿? 複式簿記とかの?」

「会社の帳簿は、帳簿係が記入する。帳簿係以外の人間がペンを持って勝手に書き込んだら、残高は合わなくなる」ロックさんは画面を指さした。「君のコードには帳簿係がいない」

「帳簿係……つまり、Order クラスが帳簿係の役割を果たしていない、ということですか?」

「そもそも帳簿係になろうとしていない。この items は丸裸で公開されている。誰でも——API呼び出し側でも、バッチ処理でも——直接手を突っ込んで明細を書き換えられる」

ロックさんがエディタにコードを書き始めた。

1
2
3
4
5
6
7
8
# 外部から items に直接 push できてしまう
push $order->items->@*, OrderItem->new(
    id => 'ITEM-X', product_name => 'Phantom',
    unit_price => 5000, quantity => 1,
);

# 外部から OrderItem の属性を直接書き換えられる
$order->items->[0]->quantity(99);

「ここだ。push $order->items->@* は、帳簿の横にペンを置いて『ご自由にどうぞ』と書いているようなものだ。そして quantity(99) は、明細の数字を消しゴムで消して好きな数を書く行為だ。帳簿の管理人は何も知らないまま、帳尻が合わなくなる」

「でも、僕は3回SQLで合計金額を直しましたよ。そのたびに正しい値に戻して——」

「君は帳簿の数字を書き直しているだけで、帳簿のルールを修復していない。帳簿係がいない限り、明日また狂う。3回繰り返された理由はそれだ」

沈黙が落ちた。3ヶ月のモグラたたきの正体を、目の前の男が一言で言い当てた。

「犯人はバグではない。犯人は整合性境界の不在——帳簿係のいない帳簿だ」ロックさんはエナジードリンクの缶を手に取った。「DDD——ドメイン駆動設計の用語では、Aggregate の不在と呼ぶ」

「Aggregate……集約、ですか」

「関連するオブジェクトを束ね、1つの単位として整合性を保証する仕組みだ。そして束ねた塊の入口に門番を立てる。その門番を Aggregate Root——集約ルートと呼ぶ」

推理披露——Aggregate Root: 整合性の門番

「まず、門番の設計から始める」

ロックさんがエディタに新しいファイルを開いた。

OrderItem——門番の管理下に置く

OrderItem の属性を外部から変更できないようにする。rwrwp に変える」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package OrderItem;
use Moo;
use v5.36;
use Types::Standard qw(Str Num Int);

has id           => (is => 'ro',  isa => Str, required => 1);
has product_name => (is => 'ro',  isa => Str, required => 1);
has unit_price   => (is => 'rwp', isa => Num, required => 1);
has quantity     => (is => 'rwp', isa => Int, required => 1);

sub subtotal ($self) {
    return $self->unit_price * $self->quantity;
}

1;

rwp って何ですか?」

「read-write private の略だ。外部からは読み取りのみ。書き込みは _set_quantity のような内部メソッド経由でしかできない。帳簿の各行に鍵をかけるようなものだね」

Order——Aggregate Root として再設計

「次に、Order を Aggregate Root として再設計する。核心は3つ。外部からの直接設定の禁止、コピーによる読み取りアクセス、操作メソッドの一元化だ」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package Order;
use Moo;
use v5.36;
use Types::Standard qw(Str Num ArrayRef InstanceOf);
use Carp qw(croak);

has id => (is => 'ro', isa => Str, required => 1);

has _items => (
    is       => 'ro',
    isa      => ArrayRef[InstanceOf['OrderItem']],
    default  => sub { [] },
    init_arg => undef,
);

has _total_amount => (
    is       => 'rwp',
    isa      => Num,
    default  => sub { 0 },
    init_arg => undef,
);

# 読み取り専用アクセス(コピーを返す)
sub items ($self) {
    return [ $self->_items->@* ];
}

sub total_amount ($self) {
    return $self->_total_amount;
}

init_arg => undef って何ですか?」

「コンストラクタでの直接設定を禁止する宣言だ。Order->new(items => [...]) と書いても無視される。帳簿を新品で渡すとき、最初から誰かの落書きが入っていたら困るだろう?」

「なるほど。じゃあ items メソッドがコピーを返すのは——」

「返した配列をいくら弄っても、元の帳簿には影響しない。閲覧用のコピーを渡し、原本は金庫にしまう。これが Aggregate Root の基本だ」

「でも、外部から $order->_items に直接アクセスされたら同じことでは?」

「アンダースコア始まりの属性名は『触るな』の慣習だ。Perl の世界では紳士協定だが、items メソッドでコピーを返している以上、正規のルートを通る限り原本には手が届かない」

「Javaの private みたいに完全に隠せないのが、少し不安です」

「Perl は信頼に基づく言語だ。壁を建てるのではなく、正しい入口を用意して、そこを通る理由を明確にする。門番は壁ではない。門番の存在が、正しい入口の場所を示す標識になる」

操作メソッド——門番を通る唯一の道

「ここからが本丸だ。アイテムの追加・削除・数量変更は、すべて Order のメソッドを経由する」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
sub add_item ($self, %args) {
    my $item = OrderItem->new(%args);
    push $self->_items->@*, $item;
    $self->_recalculate_total;
    return $item;
}

sub remove_item ($self, $item_id) {
    my @remaining = grep { $_->id ne $item_id } $self->_items->@*;
    croak "Item not found: $item_id"
        if scalar @remaining == scalar $self->_items->@*;
    $self->_items->@* = @remaining;
    $self->_recalculate_total;
    return;
}

sub update_item_quantity ($self, $item_id, $new_quantity) {
    croak "Quantity must be positive" unless $new_quantity > 0;
    my ($item) = grep { $_->id eq $item_id } $self->_items->@*;
    croak "Item not found: $item_id" unless $item;
    $item->_set_quantity($new_quantity);
    $self->_recalculate_total;
    return;
}

sub _recalculate_total ($self) {
    my $total = 0;
    $total += $_->subtotal for $self->_items->@*;
    $self->_set__total_amount($total);
    return;
}

1;

「すべてのメソッドの末尾に _recalculate_total がある。追加しても、削除しても、数量を変えても、合計金額は必ず再計算される。これが門番の仕事だ」

「合計金額を毎回再計算するのは、パフォーマンス的にどうなんですか? 明細が100行あったら——」

「100行の足し算と、月末に経理から突きつけられる不整合の手動修正——どちらが高いかね、ワトソン君」

「……それは、経理のほうが圧倒的に高いです」

「整合性のコストを見積もるとき、壊れたときの修復コストを含めないのは、見積もりの詐欺だ」

返す言葉がなかった。3ヶ月分のSQL手動修正と、経理とのやり取りと、深夜の調査。あれは全部、門番を雇わなかったツケだった。

全体像

	classDiagram
    class Order {
        -id: Str
        -_items: ArrayRef~OrderItem~
        -_total_amount: Num
        +items() ArrayRef
        +total_amount() Num
        +add_item(%args) OrderItem
        +remove_item(item_id)
        +update_item_quantity(item_id, qty)
        -_recalculate_total()
    }

    class OrderItem {
        -id: Str
        -product_name: Str
        -unit_price: Num
        -quantity: Int
        +subtotal() Num
    }

    Order *-- OrderItem : contains (private)

「……あ」

僕は画面を見つめたまま声が出た。

「だから直しても直しても壊れたのか」

3ヶ月のモグラたたき。合計金額をSQLで直しても、次に誰かが明細を直接触ったらまた壊れる。帳簿の数字を書き直しても、帳簿のルールが壊れているなら、明日また狂う。

「門番がいなかったから。帳簿のルールそのものが壊れていたんだ」

「ようやく事件の全貌が見えたようだね」

「既存の壊れたデータはどうすればいいですか」

「マイグレーションスクリプトで全注文の合計を再計算する。だが今日の仕事はそれではない。まず門番を立てることが先だ。門番がいれば、新しい不整合は生まれない」

事件の終わり——テスト通過

ロックさんの指示に従って、テストを書いた。

Aggregate Root 経由の操作で合計金額が常に正しいこと。アイテムの追加、削除、数量変更、すべての操作で total_amount が再計算されること。items メソッドが返すコピーを変更しても元の Order に影響しないこと。存在しないアイテムへの操作がエラーになること。

全テスト通過。

「……合計金額のテスト、Beforeのコードだったら確実に落ちてたやつが全部通ってます」

「帳簿係を雇ったのだから、帳簿が合うのは当然だよ」

ロックさんの口調は淡々としていた。当然のことを当然にやっただけだ、と言いたげだった。実際、そうなのかもしれない。門番を立てる。操作を一元化する。合計を再計算する。どれも特別なことではない。特別なのは、それをやっていなかった半年間のほうだ。

エピローグ

ロックさんが天秤のそばに戻った。エナジードリンクの缶を手に取り、一口飲む。

「ワトソン君、報酬の話をしよう」

「報酬……あ、そうですよね。料金はおいくらですか」

「金は要らない。ラクダ本の初版を持っていないかね。1991年刊。表紙がピンクのやつだ」

「……すみません、持ってないです」

「残念だ。では次善策として、このビルの1階の自販機で売っているD社の新作エナジードリンクを3本。比重の測定が途中でね」

「3本で……いいんですか」

「3本だ。初版のラクダ本が見つかったら、追加報酬として受け付ける。いつでも構わない」

ロックさんは天秤のメモ帳に何かを書き込んでいた。僕のことはもう意識の外にあるようだった。

階段を下りながら考えた。

自由に触れることが柔軟性だと思っていた。Order の中身を好きなところから書き換えられるのは、便利だと思っていた。でも、それは柔軟性じゃなかった。ただの無防備だ。

門番がいることで失われるのは、不正な操作の自由だけだ。正しい操作は、門番を通しても同じようにできる。むしろ門番がいるからこそ、操作した結果が壊れていないと信じられる。

1階の自販機でD社の新作を3本買った。缶を紙袋に入れて、三階のドアの前に置いておく。ノックはしなかった。比重の測定の邪魔をするのは気が引けた。

外に出ると、看板がまた目に入る。「LCI」。

……先輩、次に会ったら聞いてみよう。ラクダ本の初版、どこかで見つかりませんか、と。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
整合性境界の不在(子要素の直接操作で金額不整合)Aggregate Root(Order が門番として整合性を保証)全操作で total_amount が自動再計算。手動修正が不要に
items の丸裸公開(外部から push / 属性変更が自由)init_arg => undef + コピー返却で原本を保護外部からの直接操作が不可能に。items() はコピーを返すため安全
rwで属性が外部から変更可能rwp で内部からのみ変更可能OrderItem の quantity / unit_price は Root 経由でのみ変更

推理のステップ

  1. 問題の特定: 合計金額と明細合計の不整合が繰り返し発生。SQLでの手動修正はモグラたたき
  2. 原因の分析: Order が Aggregate Root として機能していない。items が外部に公開され、OrderItem の属性が rw で直接変更可能
  3. OrderItem の保護: rwrwp に変更し、外部からの直接変更を禁止
  4. Order を Aggregate Root に: _itemsinit_arg => undef で保護。items() はコピーを返す
  5. 操作メソッドの一元化: add_item, remove_item, update_item_quantity を Root に定義。すべての操作後に _recalculate_total で合計を再計算
  6. テスト検証: 全操作で total_amount の整合性が保たれること、コピーの安全性、エラーハンドリングを確認

ロックより

門番を立てることは、自由を奪うことではない。正しい入口を示すことだ。自由に書き込める帳簿は便利に見えるが、その便利さの代償は月末の不眠だった。門番は壁ではない。門番がいるからこそ、安心して帳簿を開ける。カワモトの後輩君にラクダ本の初版を探す余裕が生まれることを祈っているよ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。