Goにおけるヘキサゴナルアーキテクチャ:インフラにビジネスロジックを「窒息」させないために

Development tutorial - IT technology blog
Development tutorial - IT technology blog

ビジネスロジックがインフラに「侵食」される悪夢

2年間安定稼働しているGoのプロジェクトをメンテナンスしていると想像してみてください。ある日突然、コスト最適化のためにMySQLからMongoDBへ、あるいはSendGridからAWS SESへの変更を上司から命じられたとします。コードを開くと、SQL文やGORMの構造体が注文処理の関数の中に複雑に絡み合っていることに気づくでしょう。

その代償は非常に大きいです。本物のデータベースを起動せずに割引ロジックのユニットテストを書くことはできません。ストレージ層の小さなカラム変更一つで、ビジネスロジックが突然動かなくなります。これこそが、誰もが触りたがらない「大きな泥の団子(Big Ball of Mud)」の状態です。

なぜこのような間違いを犯してしまうのか?

よくある典型的な間違いは、プロジェクト開始直後にフレームワーク(GinやEcho)やORM(GORM)を選んでしまうことです。外部ライブラリによって、知らず知らずのうちにコアなデータ構造が決定されてしまいます。

ビジネスロジックが技術的な詳細(テーブルのカラム数やAPIが返すJSON形式など)を知りすぎると、システムは極めて脆弱になります。優れたシステムには、安定したコアロジックが必要です。今日PostgreSQLを使い、明日Redisを使うことになっても、売上計算の方法が変わるようなことがあってはなりません。これは、Goにおけるデザインパターンを意識した、拡張性の高いシステム設計の第一歩です。

解決策:ヘキサゴナルアーキテクチャ(Ports and Adapters)

アリスター・コバーン氏によって提唱されたヘキサゴナルアーキテクチャは、コアロジックを外部の影響から隔離することを目的としています。アイデアは非常にシンプルです。**ビジネスロジックを中心に配置し**、それをインターフェース(ポート)で包みます。外部コンポーネント(アダプター)は、これらのポートを介して接続されます。

具体的には、このフレームワークは主に3つの部分で構成されています:

  • コア(Domain/Service): 純粋なビジネスロジックが含まれる場所。外部への依存関係は一切持ちません。
  • ポート(Interfaces): コアが提供、あるいは要求する「契約」です。
  • アダプター: 実際の構成要素。例:MySQLアダプター、REST APIアダプター、gRPCアダプター。

違い:レイヤードアーキテクチャ vs ヘキサゴナルアーキテクチャ

従来の3層アーキテクチャは通常、UI -> ビジネス -> データアクセスの形式をとります。問題は、ビジネス層がデータアクセス層に直接依存していることです。

ヘキサゴナルアーキテクチャは、この依存関係を完全に逆転させます(Dependency Inversion)。UIもデータベースも、ポートを介してコアを中心に配置されます。これにより、コアは独立した「孤島」となります。インフラなしで、すべてのビジネスロジックのユニットテストを数ミリ秒で実行できるようになります。この考え方は Repository Pattern & Unit of Work の実装 を検討する際にも非常に重要です。

Goでの具体的な実装ガイド

ユーザー管理モジュールを構築してみましょう。以下は、各レイヤーを完全に分離するための標準的なディレクトリ構造です:

/internal
  /core
    /domain      # ビジネス構造体(User, Orderなど)を含む
    /ports       # インターフェースを定義
    /services    # 実際のビジネスロジック
  /adapters
    /repository  # DBの実装(Gorm, SQLXなど)
    /handler     # HTTP/gRPCの実装(Gin, Echoなど)

1. 「契約」の設定(DomainとPorts)

まず、ユーザーオブジェクトと、各パーツがどのように通信するかを定義します。例えば、CasbinによるGoの権限管理のような機能を統合する場合でも、ポートを通じて疎結合に保つことができます。

// internal/core/domain/user.go
package domain

type User struct {
    ID    int64
    Email string
    Name  string
}

// internal/core/ports/ports.go
package ports

import "project/internal/core/domain"

// Driven Port: コアがデータ保存のために要求するもの
type UserRepository interface {
    Save(user *domain.User) error
    GetByID(id int64) (*domain.User, error)
}

// Driving Port: 外部がコアを呼び出すために使用するもの
type UserService interface {
    CreateUser(email, name string) error
    GetUser(id int64) (*domain.User, error)
}

2. コアロジック(アプリケーションの心臓部)の記述

このサービスはインターフェースのみを扱います。データがMySQLに保存されるのか、テキストファイルに保存されるのかについては一切関知しません。

// internal/core/services/usersrv/service.go
package usersrv

import (
    "project/internal/core/domain"
    "project/internal/core/ports"
)

type service struct {
    repo ports.UserRepository
}

func New(repo ports.UserRepository) ports.UserService {
    return &service{repo: repo}
}

func (s *service) CreateUser(email, name string) error {
    // ここでメールアドレスのバリデーションやパスワードのハッシュ化などのロジックを追加できます
    user := &domain.User{Email: email, Name: name}
    return s.repo.Save(user)
}

3. アダプターの実装(インフラの詳細)

ここでようやく、MySQLの実際のコードを記述します。将来MongoDBに変更する必要がある場合は、coreフォルダのコードを一行も修正することなく、adaptersフォルダにmongodb.goを作成するだけで済みます。また、Goでsqlcを使ってデータベースを管理する手法は、このアダプター層において究極の型安全性を提供してくれます。

// internal/adapters/repository/mysql.go
package repository

import (
    "project/internal/core/domain"
    "gorm.io/gorm"
)

type mysqlRepo struct {
    db *gorm.DB
}

func NewMySQL(db *gorm.DB) *mysqlRepo {
    return &mysqlRepo{db: db}
}

func (r *mysqlRepo) Save(user *domain.User) error {
    return r.db.Create(user).Error
}

ヒント: ヘキサゴナルアーキテクチャを採用すると、Domain構造体とDatabase構造体の間で頻繁にデータをマッピングすることになります。複雑なJSON構造を素早く確認するために、私はよく toolcraft.app/ja/tools/developer/json-formatter を使います。このツールはJSONのフォーマットやエラーチェックが非常に速く、VS Codeに重い拡張機能を入れるよりも便利です。

4. Mainでのコンポーネントの組み立て

main.go 関数は、Dependency Injectionを通じて各パーツを接続する整備士のような役割を果たします。

func main() {
    db := initDB() // 実際の接続を初期化

    userRepo := repository.NewMySQL(db)       // アダプター
    userService := usersrv.New(userRepo)      // コア(ポートにアダプターを注入)
    handler := http.NewHandler(userService)   // トランスポートアダプター

    handler.Run()
}

公平な評価:すぐに適用すべきか?

明らかなメリット

  • テストが非常に快適: Repositoryを完全にMock化して、CreateUserのロジックをわずか1〜2秒でテストできます。
  • 恐れることなく変更可能: GORMのアップグレードやログライブラリの変更が、もはや悪夢ではなくなります。
  • クリーンで専門化されたコード: ビジネスロジックがデータベースやフレームワークのアノテーションに汚染されません。

考慮すべき制限事項

  • コード量が増える(Boilerplate): 多くのInterfaceを定義し、データマッピングを実装する必要があります。
  • 小規模プロジェクトにはOver-engineering: 1週間で終わるような単純なCRUDプロジェクトでは、このアーキテクチャは開発スピードを落とす可能性があります。

結びに

私の経験から言えるのは、6ヶ月以上運用する予定のプロジェクトや、ロジックが複雑なプロジェクトにはヘキサゴナルアーキテクチャを採用すべきだということです。システムを拡張する際、より安心して眠れるようになります。

新しいGoプロジェクトを始める際は、コードを書く前に30分時間を取ってポートを設計してみてください。この分離は、プロジェクトが巨大化し、技術スタックの変更要求が押し寄せたときに、計り知れないメリットをもたらします。さらに 実践 Go Generics の知見を組み合わせることで、より洗練されたコードベースを構築できるでしょう。

Share: