Nguyen Le Phong

ソフトウェアアーキテクチャの基礎全 6 回中第 1 回

Ports & Adapters(Hexagonal Architecture)をやさしく解説

Hexagonal Architecture の入門ガイドです。ポートとアダプターが実際に何であるか、それを成立させる唯一のルール、そしてどこまで適用すべきかを、ソロ開発者からエンタープライズチームまでに向けてわかりやすく解説します。

こんな経験、きっとあると思います。午後一つで終わるはずの機能が三日かかってしまう——「メールの送り方をちょっと直すだけ」のはずが、十二ものファイルに絡みついていたから。決済コードがデータベースを知っていて、データベースコードがWebリクエストを知っていて、そのどこか中間に——ほとんど見えないところに——あなたのプロダクトをあなたのプロダクトたらしめる本当のビジネスルールが埋まっている。

Ports & AdaptersHexagonal Architectureとも呼ばれます。Alistair Cockburn が2005年に命名)は、こういうことが起きないようにコードを整理する方法です。フレームワークではありませんし、インストールも不要で、どんな言語でも使えます。実態はシンプルな一つのアイデアに、少し大げさな名前がついているだけです。では、図と実例を使いながらゆっくりほぐしていきましょう。読み終えた頃には、いつ使うべきか——そしていつ使わないか——がはっきりわかるはずです。

問題:コードは育ち方を間違えることがある

ほとんどのアプリは同じような感じでスタートします。コントローラーがサービスを呼び、サービスがデータベースといくつかのベンダーと話し、みんなハッピー。問題は時間の経過とともに起きます。以下は、生まれてわずか数ヶ月なのにすでに頭が痛いチェックアウト処理の例です:

// checkout.ts — すべてが絡み合っている
class OrderService {
  async checkout(cart: Cart) {
    const db = new PgClient(process.env.DB_URL)        // 特定のデータベース
    const stripe = new Stripe(process.env.STRIPE_KEY)   // 特定のベンダー
    const sg = new SendGrid(process.env.SENDGRID_KEY)   // 別のベンダー

    // 本当のビジネスルールはベンダー呼び出しの間に埋もれている…
    const total = cart.items.reduce((s, i) => s + i.price, 0)
    await stripe.charges.create({ amount: total, source: cart.token })
    await db.query('INSERT INTO orders …')
    await sg.send({ to: cart.email, template: 'order-ok' })
  }
}

どの行も「間違い」ではありません。でも、ビジネスロジック(注文が何であるか、いつ有効か、「確定」とは何を意味するか)が、三つのベンダーとデータベースドライバーに絡みついています。ライブのデータベースなしにルールをテストできません。SendGrid から乗り換えるには大手術が必要です。そして新しいチームメンバーがこれを読んだとき、あなたの注文について学ぶ前に Stripe について学ぶことになります。

本質的な問いかけ

Ports & Adapters が答える問いは一つです:お金を生む部分(ビジネスルール)を、必ず入れ替えることになる部分(データベース、ベンダー、フレームワーク、UI)からどうやって分離するか?

コアアイデアを一枚の絵で

ビジネスルール——ドメイン——を中心に置きます。フレームワーク・データベースドライバー・ベンダーSDKを一切インポートすることを禁じます。そして外の世界はポート(インターフェース)を通じてのみドメインと対話でき、アダプターが反対側で現実の汚れ仕事を担います。

ドメインは中央に位置します。アダプターはポートを通じてそこに接続されます。 DRIVING SIDE · calls us DRIVEN SIDE · we call it Domain business rules, no vendors Web / Mobile UI REST / GraphQL Tests / CLI / Cron Database Email / SMS Payment vendor ● = a port (an interface the domain owns)
六角形はあくまで絵です。ドメインは中央に存在し、フレームワークやベンダーを一切インポートしません。外側のすべてはポートを通じてのみドメインに触れられます。左側のアダプターがアプリを駆動し、右側のアダプターはアプリに駆動されます。

これが全体の形です。円ではなく六角形を描く理由には愛嬌があります。六辺は、アプリには多くの入出口(Web UI、API、テスト、cronジョブ、データベース、キュー…)があることを思い出させてくれるからです。「上」と「下」しかないわけではありません。六という数字自体に意味はありません——ポートの数を数えないでください。

用語を平易な言葉で

三つの言葉がすべてを担います。専門用語なしで説明します:

  • ドメイン(コア):ビジネスルールと概念——OrderMoney、「アイテムゼロのカートはチェックアウトできない」。純粋なロジックです。テックスタックではなく、あなたのビジネスの説明として読めるべきです。
  • ポート:ドメインが所有するインターフェースで、ドメインが必要とするもの、あるいは提供するものを表します——「save(order)できる必要がある」「charge()で支払いを処理する必要がある」など。ポートはドメインの言葉で書かれた約束であり、ベンダー名は一切登場しません。
  • アダプター:ポートを実際の技術で実現する具体的なコード——PostgresOrderRepoStripeGatewayなど。アダプターこそが、Stripe・Postgres・Reactが存在を許される場所です。

そしてポートには二種類あります。ここが唯一、覚えておく価値のある細かい点です:

リクエストはドライビングアダプターからドメインを通り、ドリブンアダプターへと流れます。 PRIMARY · driving SECONDARY · driven User / Test actor Controller / CLI driving adapter Use case domain rules Repo / Gateway driven adapter DB / Vendor input port output port
同じドメイン、二種類のアダプター。ドライビング(プライマリ)アダプターはインプットポートを通じてリクエストをアプリに押し込み、ドメインはアウトプットポートを通じてドリブン(セカンダリ)アダプターへ処理を送り出します。矢印は呼び出し方向であり、依存方向ではありません。
  • ドライビング(プライマリ)アダプターは左側に位置し、アプリを呼び出します:Webコントローラー、CLIコマンド、テストなど。インプットポート(ユースケースインターフェース)を使用します。
  • ドリブン(セカンダリ)アダプターは右側に位置し、アプリから呼び出されます:データベース・メールサービス・決済APIなど。ドメインが定義したアウトプットポートを実装します。
覚え方のコツ

何かがあなたのアプリとの会話を始めるなら、それはドライビング側です。アプリが会話を始めるなら、相手はドリブン側です。「購入」をクリックする人間がドライビング側で、決済ベンダーはドリブン側です。

具体的な例:ビフォー&アフター

あのチェックアウト処理を整理してみましょう。まず、ドメインが必要とするもの——ポート——をビジネスの言葉だけで書き出します:

// ports.ts — ドメインの語彙。ベンダー名は禁止。
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Notifier        { orderConfirmed(order: Order): Promise<void> }

これでユースケースはこれらの約束だけに依存し、それ以外の何にも依存しません。声に出して読んでみてください。ベンダーリストではなく、チェックアウトの説明として聞こえるはずです。

// checkout-service.ts — ポートのみに依存し、ベンダーには絶対に依存しない
class CheckoutService {
  constructor(
    private payments: PaymentGateway,
    private orders: OrderRepository,
    private notify: Notifier,
  ) {}

  async checkout(cart: Cart): Promise<Order> {
    const order = Order.fromCart(cart)         // 純粋なビジネスルール
    await this.payments.charge(order.total, cart.token)
    await this.orders.save(order)
    await this.notify.orderConfirmed(order)
    return order
  }
}

最後に、実際のベンダーはポートを実装するアダプターの中に置きます。入れ替えは局所的な変更で済みます——テストではフェイクに差し替えることで、以前はネットワーク呼び出し一回分しかかからなかった時間に、何千ものケースを実行できます:

// adapters/*.ts — ベンダーはここに置く。実装するポートの背後に隠れている
class StripeGateway      implements PaymentGateway { /* Stripe SDK */ }
class PostgresOrderRepo  implements OrderRepository { /* SQLはここ */ }
class SendGridNotifier   implements Notifier        { /* SendGrid */ }

// テストでは本物のベンダーをインメモリのフェイクに差し替える——ネットワーク不要、ミリ秒単位:
const service = new CheckoutService(new FakePayments(), new InMemoryOrders(), new NullNotifier())

何が変わったかに注目してください。Stripe・Postgres・SendGrid は今や端にある詳細になりました。お金を生む処理は中央にあり、読みやすくテストしやすい状態で、あのベンダーたちの存在を一切知りません。

すべてを支える唯一のルール

一つだけ覚えるなら、これを覚えてください:ソースコードの依存は常に内向きです。外側のリング(アダプター・フレームワーク・I/O)は内側のリングについて知ることが許されます。中央のドメインは外側の何も知ることを許されていません

依存は常に内向き、ドメインに向かって向いています。 ADAPTERS · frameworks · databases · HTTP · I/O APPLICATION · use cases + ports Domain pure rules Outer layers know about inner layers — never the other way around.
黄金律:ソースコードの依存は常に内向きです。ドメインはデータベースの存在を知らず、データベースアダプターがドメインを知っています。外側のレイヤーをひっくり返しても、ドメインは無傷のままです。

だからこそ、Webフレームワーク全体を置き換えたり、MySQLからDynamoDBへ移行したりしても、ドメインは気づきません。依存の矢印がコアの外に向くことはないので、端での変更が内部に波及することもないのです。(依存性逆転の原則——SOLIDの「D」——を聞いたことがあれば、これがその実践です。ドメインがインターフェースを定義し、アダプターがそれに従います。)

ドライビングとドリブン、実践的に理解する

プライマリ/セカンダリの区別がなぜ日常的に重要なのでしょうか?それは誰がインターフェースを所有するかを教えてくれるからです。

  • ドリブンポート(データベース・メール)の場合、ドメインがインターフェースを所有し、アダプターがそれに合わせます。これがベンダーを自由に交換できる理由です。
  • ドライビングポート(ユースケース)の場合、アプリケーションがインターフェースを所有し、外の世界(HTTP・CLI・テストハーネス)がそれを呼び出します。これにより、同じロジックが今日はREST API、明日はgRPCエンドポイントやスケジュールジョブとして機能できます——ルールの変更なしに。

役立つリトマステスト:まったく新しい配信メカニズム(例えばSlackボット)は新しいドライビングアダプター一つだけであるべきです。まったく新しいベンダー(例えばStripeからAdyenへの乗り換え)は新しいドリブンアダプター一つだけであるべきです。どちらかがドメインを開かせるなら、何かが漏れています。

会社の規模ごとにどう見えるか

多くのアドバイスが間違えるポイントがここです——アーキテクチャを「万能サイズ」として扱ってしまう。Ports & Adapters をどの程度適用するかは、会社がどの段階にあるかに大きく依存します。正直なバージョンをお伝えします:

会社のステージ解消できる痛みどの程度適用するか
ソロ / 初期スタートアップ 急いでSQLiteと決済プロバイダーを選んだが、今はロックインされているのではと不安。 最大一〜二つのポートに留める——通常はデータベースと決済のみ。それ以外は直接呼び出しのままでOKです。ここではスピードが純粋さに勝ります。
小規模 / 成長中(≈ Series A) テストが本物のデータベースとベンダーサンドボックスを叩くため遅くて不安定、開発者が走らせなくなった。 すべてのI/Oをポートで囲む。フェイクでユニットテストがミリ秒で動き、新メンバーが一日でドメインを理解できます。
中規模 / スケールアップ 複数チームが互いに踏み合い、「SendGridからのコスト削減移行」が一ヶ月の書き直しになる。 ポートはチーム間の契約になります。ベンダーの交換は新アダプター一つで済み、移行作業にはなりません。
大企業 / エンタープライズ レガシーシステム・地域ベンダー・監査・「特定ベンダーへのロックインなし」を求める調達規則。 一つのポートに複数のアダプター(旧+新)、ストラングラーフィグ移行、ベンダー独立性を書面要件として明記。

三つの短い実例

小さなECショップ(10人)。メールを「念のため」と一メソッドのNotifierポートの後ろに隠しました。二年後、メールコストが急騰。SendGridからAmazon SESへの移行は新アダプター一つと配線の一行変更で済み、午後一つで完了しました。注文ロジック——彼らの核心——は一切触られませんでした。

スケールアップ中のフィンテック(約120人のエンジニア)。決済はある国では国内プロバイダー経由、別の国では国際プロバイダー経由で処理する必要があり、トランザクションごとに実行時に選択しなければなりませんでした。PaymentGatewayがポートだったため、「どのプロバイダーを使うか」はインターフェースの背後にあるルーティング判断になりました。コンプライアンス監査人も大喜びでした。境界が明確でテスト可能なシームだったからです。

大手銀行(1,000人超のエンジニア)。20年もののメインフレームを一夜で置き換えることはできませんでした。彼らはAccountLedgerポートを定義し、二つのアダプターを書きました——一つは旧メインフレームと通信し、もう一つは新コアバンキングシステムと通信——そして徐々にトラフィックを移行しました(「ストラングラーフィグ」)。チームは移行が水面下で進む中、ポートを対象に新機能を開発し続け、移行は見えないところで行われていました。

Ports & Adapters と従来のレイヤードアーキテクチャの比較

多くのチームは従来のレイヤードアーキテクチャ(UI が上、サービスが中間、データベースが下)から来ています。二つを比べてみましょう:

問い従来のレイヤード(UI → service → DB)Ports & Adapters
誰が誰に依存するか?トップダウン:各レイヤーが一つ下のレイヤーに依存し、データベースで終わる。すべてが内向きに依存し、ドメインで終わる。データベースはただのプラグイン。
DBなしでルールをテストできるか?通常できない——サービスがDBに直接アクセスする。できる——フェイクリポジトリを注入。DBもネットワークも不要。
ベンダーを交換するには?サービスレイヤーを触り、外側に波及する。新アダプターを一つ書くだけ。ドメインは変わらない。
ビジネスルールはどこにある?サービスとDBをまたいで散在していることが多い。ドメインに集中し、ビジネス言語で書かれている。
コストセレモニーが少ないが、時間とともにスタックへの結合が深まる。最初にインターフェースが少し増えるが、変更が加速するほど元が取れる。

最大の違いは矢印の向きです。レイヤードコードでは、最終的にすべてがデータベースに依存します。Ports & Adapters では、データベースがあなたに依存します。この逆転こそがすべての肝です。

使うべき時(使わないべき時)

良いアーキテクチャとは、労力を重要度に見合わせることです。以下の場合に Ports & Adapters を検討してください:

  • アプリに保護する価値のある本物のビジネスルールがある(テーブルへの単純なCRUDではない)。
  • ライフタイムを通じて統合——ベンダー・データベース・チャンネル——を交換・追加することが予想される。
  • テストのスピードと信頼性が重要で、高速かつ決定論的なユニットテストが欲しい。
  • 複数チームや長い寿命により明確な境界がコストを自ら回収する。

以下の場合は懐疑的に——または非常に軽く——適用してください:

  • 薄いCRUDアプリや使い捨てプロトタイプ。間接層は純粋なオーバーヘッドです。
  • 「ドメイン」が基本的にデータベーススキーマそのもの。守るべきものが何もない。
  • スピード優先のソロ開発者で、将来の書き直しコストが今のセレモニーコストより低い。
盲目的に真似しないで

価値のないCRUDエンドポイントを三つのインターフェースで包んでも「クリーン」にはなりません——読みにくくなるだけです。アーキテクチャは変更しやすさを買うために払うコストです。何も変わらないなら、何も得るものがないコストを払っていることになります。

よくある間違い(そして簡単な修正方法)

  • ポートの漏れ。PaymentGatewayStripe.Chargeオブジェクトを返しているなら、ベンダーがポートを通じて漏れています。修正:ポートは自分の型(ReceiptMoney)のみで話す。
  • 貧血ドメイン。本当のロジックがすべてアダプターにあり、ドメインがただのデータバッグなら、境界を間違った場所に引いています。修正:ルールをコアに押し込む
  • 巨大な一つのポート。40メソッドを持つ単一のIRepositoryは境界ではなく、ガラクタ引き出しです。修正:小さく目的を名前で表したポート(OrderRepositoryInventoryRepository)に分ける。
  • DTOとドメインモデルを混同する。HTTP越しに送る形はドメインエンティティではありません。修正:アダプターにワイヤー形式とドメイン間の変換を任せる。
  • すべてにインターフェース。すべてのクラスにポートが必要なわけではありません——境界を越えるもの(I/O・ベンダー・配信メカニズム)だけです。修正:痛みを感じてからポートを追加する。先回りして追加しない。

月曜日から始める方法

何も書き直す必要はありません。最も痛いところに一つの境界線を入れて、そこで止めるだけです:

  • 1. 最も痛い依存を選ぶ。通常はデータベースか不安定なベンダーです。
  • 2. 欲しい理想のインターフェースを書く。ビジネスの言葉で:save(order)であって、execSql(...)ではない。
  • 3. ベンダーコードをアダプターの後ろに移す。それを実装するアダプターに。他は何も変えなくていい。
  • 4. アダプターを注入する——インラインで生成するのではなく——そしてテスト用フェイクを一つ書く。
  • 5. 楽になったことを実感し、繰り返す——ただし、変更の頻発やテストの痛みがもう一つのポートを正当化する場所にだけ。

一週間以内に、最も厄介な統合を囲む高速テストと、ビジネスのように読めるドメインができあがります。それが全体の報酬であり、少しずつ手に入れられます。

まとめ

  • 一つのアイデア:ビジネスルールは中央に、技術は端に、インターフェースのみを通じて対話する。
  • ポート=ドメインが所有するインターフェース。アダプター=本物の実装(Stripe・Postgres・React)。
  • 依存は内向き。ドメインは外の世界を一切知らない。
  • ドライビングアダプターがアプリを呼び出し、ドリブンアダプターはアプリに呼び出される。
  • 重要度に見合った分量で:スタートアップには数ポート、エンタープライズにはベンダー独立の境界、使い捨てCRUDアプリにはほぼ不要。
  • 段階的に導入する——最も痛いシームを一つずつ。大規模な書き直しは一切不要。

このシリーズの次回は、この基礎の上に立ち、Ports & Adapters がClean ArchitectureOnion Architectureとどう関係するかを見ていきます——同じ北極星を指す三つの名前——そして実際にどこで本当に違うのかを掘り下げます。