Goにおけるヘキサゴナルアーキテクチャを用いた堅牢なアプリケーション構築
Min-jun Kim
Dev Intern · Leapcell

はじめに
ソフトウェア開発の進化し続ける状況において、機能的であるだけでなく、保守性、テスト容易性、そして変化への適応性に優れたアプリケーションを構築することは極めて重要です。プロジェクトが複雑化するにつれて、システムの初期の洗練さは、密結合されたコンポーネントの絡み合った混乱に急速に劣化し、変更を危険な作業にしてしまいます。これは多くの場合、コアビジネスロジックがデータベース、UI、または外部APIのような技術的な詳細と強く絡み合っているアーキテクチャに起因します。このブログ記事では、特にGoエコシステムにおいて、これらの課題に対する解決策を提供する強力なパラダイムであるヘキサゴナルアーキテクチャについて掘り下げます。このアーキテクチャスタイル、別名「ポートとアダプター」が、明確なビジネス境界を定義し、ドメインをインフラストラクチャの懸念から分離し、最終的にはより回復力があり、将来性のあるGoアプリケーションを構築することを可能にする方法を探ります。
ヘキサゴナルアーキテクチャの理解
実用的な側面に入る前に、ヘキサゴナルアーキテクチャの背後にあるコアコンセプトについて共通の理解を確立しましょう。
主要な用語
- ヘキサゴナルアーキテクチャ(ポートとアダプター): コアアプリケーションロジック(「ドメイン」または「ビジネスロジック」)を外部の懸念事項(データベース、ユーザーインターフェース、またはサードパーティサービスなど)から分離するアーキテクチャパターン。「制御の反転」の原則を強調し、アプリケーションがそのコアを変更することなく、さまざまな外部アクターによって駆動できるようにします。 「六角形」は、アプリケーションと対話できる複数の方法を表す単なる視覚的な比喩です。
- ポート: アプリケーションが外部世界と対話する方法の契約を定義するインターフェース。これらは、外部アクターによって使用されるためにコアアプリケーションによって公開される「駆動ポート」(API)または、コアアプリケーションが外部世界(データベースなど)から必要とするサービスである「駆動されるポート」にすることができます。これらはアプリケーションの「ソケット」と考えてください。
- アダプター: ポートの実装。これらは、外部コンポーネントの特定のテクノロジーまたはプロトコルを、アプリケーションのコアが理解できる形式(駆動されるポートの場合)に変換するか、アプリケーションの応答を、外部コンポーネントが理解できる形式(駆動ポートの場合)に変換します。これらはソケットにはまる「プラグ」です。
- ドメイン/アプリケーションコア: これはアプリケーションの心臓部であり、純粋なビジネスロジックとルールを含みます。データベース、Webフレームワーク、または特定のUIテクノロジーについては何も知りません。ポートのみを通じて対話します。
Goにおける原則と実装
ヘキサゴナルアーキテクチャの主な目標は、ドメインロジックを外部の変更から保護することです。Goでは、インターフェースがこの分離を達成する上で重要な役割を果たします。
ユーザーの作成と取得ができる簡単な「ユーザー管理」アプリケーションを考えてみましょう。
1. ドメインコアの定義
まず、コアビジネスエンティティと基本的な操作を定義します。この部分は、データベースやWebフレームワークの特定の詳細から解放されるべきです。
// internal/domain/user.go package domain import "errors" var ErrUserNotFound = errors.New("user not found") type User struct { ID string Name string Email string } // UserRepository は、ユーザーの永続化を操作するためのインターフェースを定義します。 // これは「駆動されるポート」です。なぜなら、アプリケーションコアは // ユーザーに関する外部世界をクエリする必要があるからです。 type UserRepository interface { Save(user User) error FindByID(id string) (User, error) FindByEmail(email string) (User, error) } // UserService は、ユーザー管理のためのビジネス操作を定義します。 // これもドメインコアの一部です。 type UserService struct { userRepo UserRepository // ポートに依存 } func NewUserService(repo UserRepository) *UserService { return &UserService{userRepo: repo} } func (s *UserService) RegisterUser(name, email string) (User, error) { // ビジネスルール:メールアドレスが既に存在するユーザーを確認する _, err := s.userRepo.FindByEmail(email) if err == nil { return User{}, errors.New("user with this email already exists") } if err != domain.ErrUserNotFound { return User{}, err // その他の永続化エラー } newUser := User{ ID: generateUUID(), // 例のために単純化 Name: name, Email: email, } if err := s.userRepo.Save(newUser); err != nil { return User{}, err } return newUser, nil } func (s *UserService) GetUser(id string) (User, error) { return s.userRepo.FindByID(id) } func generateUUID() string { // 実世界: "github.com/google/uuid" のようなUUIDライブラリを使用 return "some-uuid" }
UserService
は、具体的なデータベース実装ではなく、UserRepository
インターフェースのみと対話することに注意してください。これが分離の本質です。
2. ポートの定義
上記の例では、UserRepository
は駆動されるポートです。ユーザーを作成できるようにするAPIの駆動ポートを想像してみましょう。
// internal/application/ports/user_api.go package ports import "example.com/myapp/internal/domain" // UserAPIService は駆動ポートです。外部アクターは、このインターフェースを通じてアプリケーションを「駆動」します。 type UserAPIService interface { RegisterUser(name, email string) (domain.User, error) GetUser(id string) (domain.User, error) }
アプリケーションのコアはこのUserAPIService
インターフェースを実装します。
// internal/application/service.go package application import "example.com/myapp/internal/domain" // ApplicationService は UserAPIService ポートを実装します。 // ドメインサービスへの呼び出しをオーケストレーションします。 type ApplicationService struct { userService *domain.UserService } func NewApplicationService(userService *domain.UserService) *ApplicationService { return &ApplicationService{userService: userService} } func (s *ApplicationService) RegisterUser(name, email string) (domain.User, error) { return s.userService.RegisterUser(name, email) } func (s *ApplicationService) GetUser(id string) (domain.User, error) { return s.userService.GetUser(id) }
3. アダプターの実装
次に、アプリケーションコアを特定のテクノロジーに接続するためアダプテーションを作成します。
データベースアダプター(UserRepository
駆動ポートの実装):
// internal/adapters/repository/inmem_user_repo.go package repository import ( "errors" "sync" "example.com/myapp/internal/domain" ) // InMemoryUserRepository はインメモリデータベースのアダプターです。 type InMemoryUserRepository struct { users map[string]domain.User mu sync.RWMutex } func NewInMemoryUserRepository() *InMemoryUserRepository { return &InMemoryUserRepository{ users: make(map[string]domain.User), } } func (r *InMemoryUserRepository) Save(user domain.User) error { r.mu.Lock() defer r.mu.Unlock() r.users[user.ID] = user return nil } func (r *InMemoryUserRepository) FindByID(id string) (domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() user, ok := r.users[id] if !ok { return domain.User{}, domain.ErrUserNotFound } return user, nil } func (r *InMemoryUserRepository) FindByEmail(email string) (domain.User, error) { r.mu.RLock() defer r.mu.RUnlock() for _, user := range r.users { if user.Email == email { return user, nil } } return domain.User{}, domain.ErrUserNotFound }
このInMemoryUserRepository
を、domain.UserRepository
インターフェースを実装している限り、PostgreSQLUserRepository
やMongoDBUserRepository
で簡単に置き換えることができます。domain.UserService
を変更することなく可能です。
Web APIアダプター(UserAPIService
駆動ポート):
// cmd/main.go (単純化されたエントリポイント) package main import ( "encoding/json" "log" "net/http" "example.com/myapp/internal/adapters/repository" "example.com/myapp/internal/application" "example.com/myapp/internal/application/ports" "example.com/myapp/internal/domain" ) type RegisterUserRequest struct { Name string `json:"name"` Email string `json:"email"` } // UserAPIAdapter は駆動アダプター(例:HTTPハンドラー)です。 // アプリケーションのUserAPIServiceポートを利用します。 type UserAPIAdapter struct { appService ports.UserAPIService } func NewUserAPIAdapter(service ports.UserAPIService) *UserAPIAdapter { return &UserAPIAdapter{appService: service} } func (a *UserAPIAdapter) RegisterUserHandler(w http.ResponseWriter, r *http.Request) { var req RegisterUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } user, err := a.appService.RegisterUser(req.Name, req.Email) if err != nil { // 本番アプリケーションでは、より良いエラー処理のためにエラータイプを区別する if err == domain.ErrUserNotFound { http.Error(w, err.Error(), http.StatusNotFound) } else { http.Error(w, err.Error(), http.StatusInternalServerError) } return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // ... GetUserなどの他のハンドラー
4. 全ての配線
main
関数または依存性注入レイヤーで、コンポーネントを組み立てます。
// cmd/app/main.go package main import ( "log" "net/http" "example.com/myapp/internal/adapters/repository" "example.com/myapp/internal/application" "example.com/myapp/internal/domain" ) func main() { // 1. アダプターの初期化(駆動側 - リポジトリ) userRepo := repository.NewInMemoryUserRepository() // または NewPostgreSQLUserRepository() // 2. ドメインサービスの初期化(コアロジック) userService := domain.NewUserService(userRepo) // 3. アプリケーションサービスの初期化(駆動ポートを実装) appService := application.NewApplicationService(userService) // 4. アダプターの初期化(駆動される側 - Web API) userAPIAdapter := &UserAPIAdapter{appService: appService} // Webクライアントの駆動ポートを実装 // Webルートの設定 http.HandleFunc("/users", userAPIAdapter.RegisterUserHandler) // http.HandleFunc("/users/{id}", userAPIAdapter.GetUserHandler) // 例 log.Println("Server starting on :8080") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatalf("Server failed: %v", err) } }
利点と応用
- 分離: コアドメインロジックは、インフラストラクチャの詳細を知らずに保たれます。これにより、独立してテスト可能で移植性が高くなります。
- テスト容易性: ポートのモック実装(例:
UserRepository
のInMemoryUserRepository
)を使用して、ドメインとアプリケーションサービスを簡単にテストできます。これにより、迅速で信頼性の高い単体テストと統合テストが促進されます。 - 保守性および適応性: 関係データベースからNoSQLデータベースに切り替えたり、メッセージキューを変更したりする場合、対応するアダプターのみを変更または交換する必要があります。コアビジネスロジックは変更されません。
- 明確な境界: アーキテクチャは、懸念事項の明確な分離を強制し、開発者が新しいロジックを配置したり、既存のコードを見つけたりする場所を理解しやすくします。
結論
ヘキサゴナルアーキテクチャ、またはポートとアダプターは、堅牢で、テスト可能で、変更に強いGoアプリケーションを構築するための非常に効果的な方法を提供します。インターフェースをポートとして細心の注意を払って定義し、特定のテクノロジーをアダプターとして実装することで、貴重なビジネスロジックを外部の懸念事項の不安定な性質から保護する柔軟なシステムを作成します。このアーキテクチャスタイルは、開発者に高品質で適応性の高いソフトウェアを提供することを可能にし、時間のテストに耐え、明確なビジネス境界と容易な進化を保証します。