Goでヘキサゴナルアーキテクチャのサンプルプログラムを作ってみる

ヘキサゴナルアーキテクチャをできるだけ実用的&シンプルに考えてみる試みです。
ヘキサゴナルアーキテクチャを全く知らない人orなんとなく理解している人が実際にプログラムを書けるようになることができることを目標にしています。

また、正しい知識が欲しい人向けの記事ではないのでご注意ください。
正しい知識は保証できません。

では、Goでヘキサゴナルアーキテクチャのサンプルプログラムを作ったので、それをベースに解説します。

ヘキサゴナルアーキテクチャとは

可能な限り、実用的な情報だけを書きます。

概要

ヘキサゴナルアーキテクチャAlistair Cockburnによって提唱されました。
別名ポート&アダプタといいます。

ビジネスロジック外の世界(GUI、データベース、RestAPIなど)を分離するためのものです。
“外の世界“の部分をポートアダプタで整理するのがヘキサゴナルアーキテクチャの特徴です。

「ビジネスロジック」と「外の世界」

以下の図に3つの登場人物コア(Core)プライマリポート(Primary Port)セカンダリポート(Sercondary Port)が登場します。

概要図

コアはビジネスロジックのことです
ドメインやアプリケーションサービスなどの実装をします。

プライマリポートとセカンダリポートは外の世界と接続するための部分です。
ユーザ側プライマリ永続化側(DBなど)セカンダリです。

この3つの登場人物(コア、プライマリ、セカンダリ)を整理しながら実装するのが重要そうです。

ポートとアダプタについて

ポートとアダプタの関係を理解することも重要です。
ここでも細かい説明を省略し、シンプルに説明します。

用語説明具体的な実装内容
ポートWhatを実装する場所インタフェースを定義する
アダプタHowを実装する場所インタフェースに対するロジックを実装する。

インタフェースを実装することで、モックを使ったテストがしやすくなります。

サンプルプログラムのディレクトリ構成

以下のようなディレクトリ構成にしてみました。

├── core
│   ├── domain
│   └── services
├── primary
│   ├── adapter
│   └── port
└── secondary
    ├── adapter
    └── port

上記のようにルートディレクトリに3つの登場人物(コア、プライマリ、セカンダリ)を用意します。
プライマリとセカンダリの下にはそれぞれポートとアダプタを用意します。

構成

主に利用するライブラリは以下です。

ライブラリバージョン
Gormv1.25.12

実際に作ってみたアプリケーション

動画のように、CRUDを実装しました。

GitHub

さきにGitHubのURLを置いておきます。

GitHub – taako-502/go-hexagonal-user-m…

GitHub – taako-502/go-hexagonal-user-m…

Goでヘキサゴナルアーキテクチャのサンプルプログラムを作成してみました。. Contribute to taako-502/go-hexagonal-user-management development by creating an ac…

Goでヘキサゴナルアーキテクチャのサンプルプログラムを作成してみました。. Contribute to taako-502/go-hexagonal-user-management development by creating an ac…

コード説明

ここではユーザの一覧を取得する処理(FindAll)を元に説明しようと思います。

セカンダリポート

データベースに接続するためのインタフェースをsecondary/secondary_port/user.goに作成します。

type UserRepository interface {
  FindAll() ([]model.User, error)
  ・・・(略)・・・
}

セカンダリアダプタ

セカンダリポートで定義したインタフェースに対してアダプタ(secondary/adapter/user_secondary_adapter/find_all.go)に実装していきます。

func (a *userSecondaryAdapter) FindAll() ([]model.User, error) {
  var users []model.User
  result := a.Db.Find(&users)
  if result.Error != nil {
    return nil, fmt.Errorf("gorm.Find: %w", result.Error)
  }
  if len(users) == 0 {
    return nil, fmt.Errorf("gorm.Find: %w", ErrUserNotFound)
  }
  return users, nil
}

アプリケーションサービス

アプリケーションサービスには2つのファイルを用意します。

一つ目はロジックです。
セカンダリアダプタを呼び出します。
エラーの付け替えなどもここで行なっています。

func (u UserService)FindAll(a secondary_port.UserRepository) ([]domain.User, error) {
  users, err := a.FindAll()
  if errors.Is(err, UserNotFoundError) {
    return nil, UserNotFoundError
  } else if err != nil {
    return nil, err
  }
  return users, nil
}

structも用意しておきます。
これはのちほどserver.goの実装の際に利用します。

type UserService struct{}

func NewUserService() UserService {
  return UserService{}
}

プライマリーポート

primary/primary_port/user.goに以下のようにインタフェースを実装します。


type User interface {
  FindAll(a secondary_port.UserRepository) ([]model.User, error)
  ・・・(略)・・・
}

プライマリアダプタ

ポートで定義したインタフェースに対してアダプタ(primary/adapter/user_primary_adapter/find_all.go)を実装していきます。

func (a *UserPrimaryAdapter) FindAll(u user_service.UserService, ur secondary_port.UserRepository) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    users, err := u.FindAll(ur)
    if err != nil {
      if errors.Is(err, user_service.ErrUserNotFound) {
        http.Error(w, err.Error(), http.StatusNotFound)
      } else {
        http.Error(w, err.Error(), http.StatusInternalServerError)
      }
      return
    }

    var responses []UserResponse
    for _, user := range users {
      responses = append(responses, UserResponse{
        ID:       user.ID,
        Username: user.Username,
        Email:    user.Email,
      })
    }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(responses); err != nil {
      http.Error(w, "Failed to encode response", http.StatusInternalServerError)
      return
    }
  })
}

依存性の注入をするために、引数にセカンダリポートのインタフェースを持ちます。

ルーティング(server.go)

API サーバーの起点となるserver.goで以下のように定義します。

func main() {
  db := dbInit()

  // DI
  userService := user_service.UserService{}
  userRepo := user_secondary_adapter.NewUserSecondaryAdapter(db)
  pa := user_primary_adapter.NewUserPrimaryAdapter(validator.New())

  // net/http 用のルータ作成
  mux := http.NewServeMux()
  mux.Handle("GET /users", pa.FindAll(userService, userRepo))
  mux.Handle("POST /user", pa.Create(userService, userRepo))
  mux.Handle("PUT /user/{id}", pa.Update(userService, userRepo))
  mux.Handle("DELETE /user/{id}", pa.Delete(userService, userRepo))

  // CORS ミドルウェアを挟む
  handler := corsMiddleware(mux)

  // サーバー起動
  fmt.Println("Server running on :8080")
  log.Fatal(http.ListenAndServe(":8080", handler))
}

テストについて

ヘキサゴナルアーキテクチャ最大の利点の一つがテストのしやすさです。
ここでは、フェイクオブジェクトを利用したテストについて説明します。

フェイクオブジェクトに関しては「フェイクオブジェクトを活用したテストコードの信頼性の改善」を参考に実装しています。

アプリケーションサービスに対するテスト

セカンダリアダプタのフェイクオブジェクトを利用することで高速・高カバレッジのテストが可能です。

まず以下のようにフェイクオブジェクトを作成します。
フェイクオブジェクトはsecondary/adapter/user_secondary_adapter/fake.goに配置しておきます。

type fakeUserRepository struct {
  findAllUser []domain.User
}

func NewFakeUserRepository() secondary_port.UserRepository {
  return &fakeUserRepository{
    findAllUser: []model.User{
      {ID: 1, Username: "user1", Email: "user1@example.com"},
      {ID: 2, Username: "user2", Email: "user2@example.com"},
    },
  }
}

func (r *fakeUserRepository) FindAll() ([]model.User, error) {
  return r.findAllUser, nil
}

次にテストを書きます。
ポートを通じて、アダプタをフェイクオブジェクトに置き換えます。

func TestUserService_FindAll(t *testing.T) {
  u := NewUserService()
  repository := user_secondary_adapter.NewFakeUserRepository()
  want := []model.User{
    {ID: 1, Username: "user1", Email: "user1@example.com"},
    {ID: 2, Username: "user2", Email: "user2@example.com"},
  }

  t.Run("Success", func(t *testing.T) {
    result, err := u.FindAll(repository)
    require.NoError(t, err)
    require.EqualValues(t, want, result)
  })
}

プライマリアダプタに対するテスト

プライマリアダプタもアプリケーションサービスと同じようにテストできます。

func TestUserPrimaryAdapter_FindAll(t *testing.T) {
  a := NewUserPrimaryAdapter(validator.New())
  u := user_service.NewUserService()
  fake := user_secondary_adapter.NewFakeUserRepository()

  type args struct {
    u  user_service.UserService
    ur secondary_port.UserRepository
  }
  tests := []struct {
    name       string
    args       args
    want       []UserResponse
    wantStatus int
  }{
    {
      name: "Success",
      args: args{u: u, ur: fake},
      want: []UserResponse{
        {ID: 1, Username: "user1", Email: "user1@example.com"},
        {ID: 2, Username: "user2", Email: "user2@example.com"},
      },
      wantStatus: http.StatusOK,
    },
  }

  for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
      req, err := http.NewRequest("GET", "/users", nil)
      if err != nil {
        t.Fatal(err)
      }
      rr := httptest.NewRecorder()
      handler := a.FindAll(tt.args.u, tt.args.ur)
      handler.ServeHTTP(rr, req)

      if status := rr.Code; status != tt.wantStatus {
        t.Errorf("status code got %v, want %v", status, tt.wantStatus)
      }

      var actual []UserResponse
      if err := json.Unmarshal(rr.Body.Bytes(), &actual); err != nil {
        t.Fatalf("failed to unmarshal response: %v", err)
      }

      // 期待値と実際のレスポンスを比較
      if !reflect.DeepEqual(actual, tt.want) {
        t.Errorf("unexpected response: got %+v, want %+v", actual, tt.want)
      }
    })
  }
}

感想

プライマリーアダプターのファイル量が多くある印象があります。ファイルの分け方は工夫したほうがよさそうだと思いました。

ヘキサゴナルアーキテクチャでは登場人物が内部(コア)と外部(アダプタ&ポート)の2つに分けることができて、シンプルに考えることができると思いました。

さいごに

ややこしいですが、先ほど添付したGitHubのリポジトリ(go-hexagonal-user-management)をgit cloneしてテストを動かしてみるとよりわかりやすいかもしれないです。

明らかなる間違いがあればコメント等で指摘いただけると嬉しいです。