KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

アーキテクチャ、破壊してみた

モジュールバランスを破壊する
こんにちは、エンジニアのT.Mです。今日は私の代わりに、私の中のYAGNI🫠なペルソナが記事を書いてくれるそうです。

YAGNI🫠:「強固なアーキテクチャって、堅苦しいよね?なんだか冗長に見えるし、何の意味があって分かれているのか、理解しがたいことばっかり!業務では絶対やれないこと、ブログではやっちゃおう!!!」

なんだか、気分が悪くなってきました。しかし、ここは温かい目で見守ることにします。

YAGNIは「You Aren't Going to Need it」の略。 「機能は実際に必要となるまでは追加しないのがよい」ということです

🫠「ここからは天才プログラマーであるところの俺様が、シンプルなコードってのを見せてやるよ」

T.M. 流石に心配なので、後ろから口を出すことにします。

堅苦しく、長いコード

🫠今回取り組むのはこのコード。飲食店の会計を行うCheckoutモジュールで、チップ込みの合計金額を計算するためのコードらしい。

契約結合

namespace Checkout;

// チップ計算 契約
internal interface ITipCalculator
{
    Money Calculate(Money subtotal, TipRate rate);
}

// チップ計算器
internal sealed class PercentageTipCalculator : ITipCalculator
{
    public Money Calculate(Money subtotal, TipRate rate) =>
        subtotal with { Amount = subtotal.Amount * rate.Value };
}

// 会計ユースケースの契約(ファサード)
public interface ICheckoutWorkflow
{
    Receipt Finalize(Order order);
}

// 会計ユースケースの実装
public sealed class CheckoutWorkflow(ITipCalculator tip) : ICheckoutWorkflow
{
    public Receipt Finalize(Order order)
    {
        var tipAmount = tip.Calculate(order.Subtotal.Value, order.TipRate);
        var total     = order.Subtotal.Value with { Amount = order.Subtotal.Value.Amount + tipAmount.Amount };
        return new Receipt(order.Id, total);
    }
}

// 値オブジェクトの定義 ・・・省略・・・

🫠なんか長かったので一旦後半は省略、後ほどいい感じにしてやる。

🫠そんで、このモジュールは以下のように使われている。

// 利用側:ICheckoutWorkflow のみ知っている
public class OrderController(ICheckoutWorkflow checkout)
{
    public Receipt Submit(Order order) => checkout.Finalize(order);
}

🫠なんか型でガチガチだし、インターフェースが2段階になってる。 どうやら、やっていることは「購入金額 × チップ率」を足して合計を返すだけらしいが、それをやるだけにこんなたくさんのコードが必要なのか? もっと"シンプル"にしてやろう。

🫠 なんか後ろから、

「お前は"Simple"と"Easy"を混同している」

という声が聞こえるが、無視して進めることにしよう

zenn.dev

このインターフェース、要る?

🫠 CheckoutWorkflow って結局のところ、中で PercentageTipCalculator を呼んでいるだけじゃん。利用側はインターフェースを一段噛まされて、実際の計算器に辿り着くまでに迷子になる。なら、一段減らせばシンプルになるな

🫠 何?

これはファサード(窓口)パターンと言って、モジュールの玄関口を一つにして複雑さを減らすんだ

🫠って?よくわからないけどF2キーで実装に飛べないのはちょっと… 

選択してCtrl + F12で飛べる(VSCodeや Visual Studioの場合)

🫠そんなのいちいち覚えてらんないよ…俺は"慣れた"手段で仕事がしたいんだ。

// Checkout モジュール:ファサード(ICheckoutWorkflow)を消す
namespace Checkout;

// public に格上げ:内部の「部品」が外から見えるようになる
public interface ITipCalculator
{
    // チップ込みの合計を返す
    Money ApplyTo(Money subtotal, TipRate rate);
}

public sealed class PercentageTipCalculator : ITipCalculator
{
    public Money ApplyTo(Money subtotal, TipRate rate)
    {
        var tipAmount = subtotal.Amount * rate.Value;
        return subtotal with { Amount = subtotal.Amount + tipAmount };
    }
}
// 利用側:部品を直接呼ぶ
public class OrderController(ITipCalculator tip)
{
    public Receipt Submit(Order order)
    {
        var total = tip.ApplyTo(order.Subtotal.Value, order.TipRate);
        return new Receipt(order.Id, total);
    }
}

🫠これでヨシ!結局やってること同じだよね?1段減った分、スッキリしただろ?

🫠え?なんか文句あるの?

例えば、税金を計算するITaxCalculator が増えたら、利用側はそれも自分で足さなきゃいけないだろ

🫠 そうだとしても、また必要になったら、そのときにファサードとやらを足せばいいだろ?今そんな要件ないんだし。

ファサードがあれば、Checkout チームが内部の部品構成を好きに動かしても利用側には影響しなかった

インターフェースをはがしたことで、どの計算器がどう並んでいるかという内部構造が利用側に漏出するようになった

🫠何言ってるかよくわからないんだけど…?

契約結合モデル結合に変化した」

🫠…?何をおっしゃっているのだかさっぱりですな

なんでわざわざ値をラップするわけ?

🫠お次は、先ほど省略したここのコードだ。謎に値をラップしている。

namespace Checkout;

// 値オブジェクト
public sealed record Money(decimal Amount, string Currency);
public sealed record Subtotal(Money Value);
public sealed record TipRate
{
    public decimal Value { get; }
    public TipRate(decimal value)
    {
        if (value < 0m || value > 1m)
            throw new ArgumentOutOfRangeException(nameof(value));
        Value = value;
    }
}
public sealed record Order(OrderId Id, Subtotal Subtotal, TipRate TipRate);
public sealed record Receipt(OrderId OrderId, Money Total);

🫠レコード?とかいうのは、クラスの亜種で、なんか値を扱うときに使うヤツらしい?でも、そもそもならプリミティブ型でよくない?

🫠インターフェース経由で呼ぶのも面倒だし、ついでに static メソッドにしちまおう。

namespace Checkout;

public static class TipCalculator
{
    public static decimal Calculate(decimal subtotal, decimal rate)
    {
        if (rate < 0m || rate > 1m)
            throw new ArgumentOutOfRangeException(nameof(rate));
        return Math.Round(subtotal * rate, 2);
    }
}

🫠ほら、これで Money とか TipRate とかいちいち宣言しなくて良くなりましたよ?俺ってやっぱ天才…?

🫠え?

「レートが0〜1の範囲内でなければならない」というビジネスルールはレートという値自身が保証すべき

🫠どこでチェックしても同じでしょ。

🫠何?まだ文句あるの?

「それに、TipRateTaxRate が型で区別できなくなった」「取り違えたときにコンパイラで気づけないだろ」

🫠変数名をちゃんとしてればそんなこと起こらないよ、もっと人間を信じなって。

// 利用側:decimal の意味と 0〜1 の制約を"暗黙的に"知っている必要がある
public class OrderController
{
    public Receipt Submit(Order order)
    {
        var tipRate = 0.15m;
        var taxRate = 0.08m;
        var tip     = TipCalculator.Calculate(order.SubtotalAmount, taxRate);
        // ↑ うっかり taxRate を渡しても型的にはまったく同じ。コンパイラで気づけない
        var total   = Math.Round(order.SubtotalAmount + tip, 2);  // 丸め方針も利用側で実装が必要になる
        return new Receipt(order.Id, new Money(total, "JPY"));
    }
}

🫠別にそんなの一回気をつければいいんだし、どうってことないでしょ?

値オブジェクトを無くしたせいで、利用側は、Checkout内部のビジネスルール(制約・丸め方針)を自分で覚えている必要がある

モデル結合機能結合に変化した」

🫠…?よくわかんないけど、さっきから難しい言葉でごまかそうとするの止めてもらえる?

コピペでいいよコピペで、既存機能触りたくないし

…しばらく経って、返金(Refund)機能を作ることになった。返金でも小計・チップ・丸めの扱いが必要だ。

🫠既存の機能触るとテストする個所が増えるなぁ… Checkout のロジックに似てるし、コピペでよくない?

// 返金サービス:同じロジックを独自にコピー
namespace Refunds;

public class RefundService
{
    public Refund Calculate(Order order)
    {
        // Checkout 側のルールをコピペ。ただし返金なので 0〜1 のガードは"いらない気がする"ので外した
        var tip   = Math.Round(order.SubtotalAmount * order.TipRateValue, 2);
        var total = Math.Round(order.SubtotalAmount + tip, 2);
        return new Refund(order.Id, total);
    }
}

🫠これで既存機能に影響はないし、コピペだから挙動も同じになったでしょ。

"割引→税→チップの順で適用する"や"小数第2位で丸める"のようなビジネスルールが複数の場所に重複している

🫠…?だから何?なんか困ることある?

"重複した機能"は機能結合の中でも、暗黙的で最も強固な結合

仕様変更が発生したときにすべてのコピーに反映できる保証はない

🫠…?ちゃんと調べればその時に気づくでしょ。

変更のたびにコードベースを関連ワードでgrepしたいのか?コピー後に変数名やメソッド名を変えて周囲に馴染ませたら、何をgrepすればコピーを追えるかもわからなくなるぞ

じゃあ共通化すればいいんでしょ?

🫠分かった分かった、そんなに言うなら同じ処理を纏めておけばいいんでしょ?

// Util.cs:Checkout と Refund の"似たような計算"を1つに纏めた
namespace Shared;

public static class PaymentUtil
{
    // rate のガードが要るかどうかは呼び出し側の事情なのでフラグにした
    public static decimal Calculate(
        decimal amount,
        decimal rate,
        bool requireRateGuard = true)
    {
        if (requireRateGuard && (rate < 0m || rate > 1m))
            throw new ArgumentOutOfRangeException(nameof(rate));

        var applied = Math.Round(amount * rate, 2);
        return Math.Round(amount + applied, 2);
    }
}
// Checkout 利用側:デフォルト引数を暗黙的に利用する
public class OrderController
{
    public Receipt Submit(Order order)
    {
        var total = PaymentUtil.Calculate(order.SubtotalAmount, order.TipRateValue);
        return new Receipt(order.Id, new Money(total, "JPY"));
    }
}

// Refund 利用側:「うちは要件が違うので」とフラグで切り替え
public class RefundService
{
    public Refund Calculate(Order order)
    {
        var total = PaymentUtil.Calculate(
            order.SubtotalAmount,
            order.TipRateValue,
            requireRateGuard: false);  // 返金ではガード不要(と思って外した)
        return new Refund(order.Id, total);
    }
}

🫠ほら、これで満足?これってDRY(Don't Repeat Yourself)原則っていうんだろ?知ってるよ?

🫠え?

「違うものを同じところにまとめるくらいなら別々の方がマシだった、会計と返金のどっちかがUtilを変更したらもう片方が引きずられてバグる」

「しかも bool requireRateGuard利用側に内部の分岐を決めさせている。これは機能結合のうち、制御結合(control coupling)と呼ばれる。これはこれで厄介な形だ」

🫠もう、まとめりゃいいのか別々にすりゃいいのかはっきりしてくれよ…

共通化と抽象化は違う、DRY原則は抽象化の話だ

🫠何?まだあるの?

正しく抽象化されていた値オブジェクトを消して、無理やり共通化しようとしたのが制御結合に至った原因

🫠…?よくわからないけど、その抽象化とやらの話を延々するより、さっさと作るほうが効果的だろ?

APIできるのなんか待ってらんない、DB直で覗いちゃおう

🫠人手が足りないってので別のチームに移動した。…今度は「現在保留中の会計一覧」を表示するダッシュボードが必要らしい。

🫠 Checkoutチームに「保留中会計を返すAPIくれ」ってお願いしたら、「仕様整理にちょっと時間ください」って返された…こっちだって急いでるのに、前のよしみで融通してくれてもいいじゃん。

🫠 たしか、PendingReceiptsっていうテーブルだったよな…

// Checkoutモジュール:内部のテーブル
namespace Checkout;

internal class CheckoutDbContext : DbContext
{
    public DbSet<PendingReceiptRow> PendingReceipts { get; set; }
}

internal class PendingReceiptRow
{
    public Guid OrderId { get; set; }
    public decimal Subtotal { get; set; }
    public decimal Tax { get; set; }
    public decimal Tip { get; set; }
}

🫠 なんかすげぇ急かされてるし、もう、直接SELECTすれば早くない?

// ダッシュボード側:Checkoutの内部テーブルを生SQLで直接読む
namespace Dashboard;

public class PendingCheckoutsView(IDbConnection db)
{
    public IReadOnlyList<PendingSummary> GetAll()
    {
        // Checkoutチームが"内部"と思っているテーブルを横から覗く
        var rows = db.Query<(Guid OrderId, decimal Subtotal, decimal Tax, decimal Tip)>(
            "SELECT OrderId, Subtotal, Tax, Tip FROM checkout.PendingReceipts");

        return rows
            .Select(r => new PendingSummary(r.OrderId, r.Subtotal + r.Tax + r.Tip))
            .ToList();
    }
}

🫠 SQL書けば一瞬じゃん。API待ってらんないよ。

「Checkoutチームは、PendingReceiptRow が外から参照されていることを知らない

🫠 え?

「カラム名を変えたり、テーブルを別スキーマに移しただけで、ダッシュボードがサイレントに壊れる。コンパイルは通るから、本番で『お客様の保留会計が突然表示されない』という形で事故になる」

🫠 …じゃあCheckoutチームに『テーブル変えないで』って言っとけばいいじゃん。

それはもう、Checkoutチームが自分のモジュールを直すたびに"他に誰が内部を見てるか"を毎回調査しなきゃいけないってことだ

機能結合から侵入結合に変化した。これは最も頑固な結合だ」

🫠 ……


ハッ…!私は一体何を、なんだかすごく頭が痛い…

何が起こっていたのか

茶番はさておき、YAGNI🫠の感覚はこうです。

  • モジュールの契約を柔らかくしたい
  • 早すぎる実装をしたくない
  • とにかく全体を短くしたい

これは一概に否定すべき感覚ではないと考えます。 実際、🫠は 特定の条件下では正しい 事も沢山言っています。問題は、彼が一貫して「このコードは変わらない」「この要件は増えない」という 低変動性を暗黙の前提 としていることです。

結合強度の4段階

すでにお気づきの方もいらっしゃると思いますが、本ブログのモジュール結合についての定義は、Vlad Khononov の『ソフトウェア設計の結合バランス』に基づいています。

ソフトウェア設計の結合バランス(Vlad Khononov)— Amazon

Khononovの定義では、モジュール結合は以下の4つの段階に分類されます。(実際にはコナーセンス等を使ってそれぞれの段階の内部でも細かくレベルがあるのですが、簡単のために今回はそこには踏み込みませんでした。)

「結合レベルとその複雑性」&「共有される変更理由」の関係

YAGNI🫠が修正を加えるたびに、そのコンポーネントは「より多くの知識」を利用する側に漏らしていきました。Vlad Khononov の Balanced Coupling モデルでは、モジュール間で共有される知識の量によって、結合を4段階に分類します(弱い順):

段階 利用側が Checkout について知っていること 典型パターン 変更の波及
契約結合 (Contract) Finalize(Order) → Receipt のシグネチャだけ Facade / DTO / Open-host service 契約面を守れば内部は自由
モデル結合 (Model) 内部の計算者インターフェース群 内部モデルをそのまま公開して契約として使う 内部モデルの変更が利用側に波及
機能結合 (Functional) 計算順序・丸め・不変条件などのビジネスルール 同じロジックを複数箇所にコピー ルール変更が全コピーに追随しないと不整合
侵入結合 (Intrusive) 内部テーブル構造・internal メソッドの存在 InternalsVisibleTo, 他モジュールの DB 直読 非公開の変更がサイレントで利用側を壊す

変動性

Vlad Khononov のモジュール結合バランスモデルでは、結合を評価するもう一つの軸として 変動性(Volatility) があります。仮に結合が強くても、そのコンポーネントが「事実上一生変わらない」なら実害はほぼ発生しません。逆に結合が弱くても、頻繁に変わる領域ではほんの少しの知識漏出が継続的なツラみになります。つまり、YAGNIは変動性が低い領域では正しいが、高い領域では負債になりうるのです。

ビジネスの中核領域(コアサブドメイン)は、変動性が高い領域の典型です。🫠が冒頭で"堅苦しい"と呼んだ構造の多くは、変動性に備えるための投資だったと言えるでしょう。 契約・値オブジェクト・Facade──いずれも「将来の変更を、利用側に漏らさずに内側で吸収する」ためのしくみです。

今回、そもそも修正対象のCheckout領域が、ビジネスにおいて中核的なのかそれとも補助的役割に留まるのか明らかにしていませんでした。 そのため、実はYAGNI🫠が間違っているとは限らず、変動性が0(作って終わり)なら、彼の言うことはむしろ正しいとも言えるでしょう。 一方で、Checkout領域がビジネスにおいて中核的なのか領域ならば、変更容易性を高く保つ必要があります。他社の製品の差別化のためには、新たな課題解決の方法を提示し続ける必要があるからです。

下から読むと、リファクタリング

心の安寧を取り戻す

この記事では、あえてより弱い結合から、知識を漏出させて強固な結合に変化されていく形を取ってみました。 そのため、設計にこだわりのある方には、読むのが苦痛の記事だったかもしれません。 この記事を下から読み直して、リファクタリングされていく気持ちよさを味わい、精神の安寧を得ていただければ幸いです。

下から読むと 何が起きているか 結合の移動
共有DBの直読をやめ、Checkoutに公開APIを要求する 内部テーブルへの侵入を、契約の向こう側へ戻す 侵入結合 → 機能結合(制御結合)
PaymentUtil の偶発的な共通化を解体し、Refund の重複ロジックも取り除く 「似ているだけで違うもの」を同居させていた暗黙の共有をほどく 機能結合(制御結合) → 機能結合(重複した機能)
TipRate / Money / 丸め方針を型と責務に戻す 不変条件とビジネスルールを型・1箇所に押し込める 機能結合(重複した機能) → モデル結合
ICheckoutWorkflow Facade を復活し、計算器を internal に戻す 内部構造の露出を契約で包み直す モデル結合 → 契約結合

※細かい点の注意:『ソフトウェア設計の結合バランス』では、先に示したグラフの通り、機能結合のうち重複した機能は、機能結合の中では最も暗黙的で強い結合とされています。一方、制御結合については、機能結合に属するとされていますが、どのレベルに属すかまでは書かれていませんでした。そのため、重複した機能をUtilにすることは、若干結合を弱めることに貢献しているかもしれません。それからすると、結合を強める順に例を書くなら、制御結合→重複した機能にすべきだったかもしれませんが、今回はストーリーのあるある感のために順番を入れ替えています。「適切でない共通化(誤った抽象化)は、結合レベルの改善にあまり貢献できない」ということをくみ取っていただければ幸いです。

距離

今回考慮しなかったのですが、実際にはもう一つ、"距離"という重要な概念があり、これを紹介することなしには、本当に適切なコンポーネント結合を定義することはできません。例えば、

  • コンポーネント同士が同じパッケージにあるのか、ライブラリになっているのか
  • 同期処理なのか非同期処理なのか
  • ライフサイクルは同じなのか、チームは別々なのか同じなのか

こういった要素も含めて初めて適切なモジュール結合の「バランス」を見極めることができます。しかし紙面の関係上、ここでは紹介できませんので、関心のある方はぜひ実際に本をお読みいただければ幸いです。多くの有名なエンジニアのお墨付きである、素晴らしい本です。

おまけ

ちなみに、Vlad Khononov のモジュール結合バランスに関する考えは、以下のリポジトリで AI エージェント用の skills にもなっています。こちらを一読するだけでも大変勉強になるかと思います。本と合わせて非常におすすめです。

github.com

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp