Go言語を利用したAPIの最適構成について考える

結論

先に結論を書くと、Goである程度の規模を想定したAPIサーバーを構築する場合、まずは以下の構成を検討するのがよいと考えている。

HTTP APIを構築するための主要なライブラリには、danielgtaylor/humaを利用する。それ以外については、データベースドライバーなど、実際の要件に応じて必要なものだけを追加する。

ディレクトリ構成は以下を基本とする。

.
├── cmd
│   ├── api
│   └── swagger
├── docs
│   └── openapi
├── internal
│   ├── config
│   ├── cqrs
│   │   ├── query
│   │   └── readmodel
│   ├── domain
│   │   ├── entity
│   │   ├── repository
│   │   └── vo
│   ├── infrastructure
│   │   └── {db_name}
│   │       └── {domain_name}
│   ├── presentation
│   │   ├── {domain_name}_handler
│   │   ├── middleware
│   │   └── route
│   └── usecase
└── migrations

もちろん、常にこの構成が最適とは限らない。

小規模なAPIであればnet/httpだけで十分な場合もある。ビジネスロジックがほとんどなければ、独立したusecase層を設ける必要もない。レイヤーやインターフェースを形式的に増やせば、かえって実装と理解のコストが高くなる。

あくまで、ある程度の規模まで成長するAPIサーバーの初期構想として、この構成から検討を始めるのがよいという結論である。

ライブラリ選定において見直すべき選択肢

過去に主流だったという理由だけで、現在は標準ライブラリで代替できるライブラリを使い続ける必要はない。

以下のライブラリを利用する場合は、標準ライブラリや別の選択肢では不足する理由を明確にした方がよい。

対象ライブラリ検討するべきライブラリ
Echo、Gin、Gorilla Mux、ChiHuma + net/http、Fuego
pkg/errorserrors
Zap、Zerologlog/slog
swaggo/swagHuma、Fuego
go-multierrorerrors.Join
google/uuiduuid

これは、これらのライブラリが利用できない、あるいは全面的に劣っているという意味ではない。

既存システムとの統一、性能要件、提供機能、チーム内の知識など、採用する明確な理由があるなら使えばよい。問題なのは、過去に使われていたという理由だけで無条件に採用することである。

Goでは、まず標準ライブラリによるシンプルな実装を検討するのがよい。外部ライブラリを追加すれば、依存関係の更新、互換性の確認、脆弱性対応などのメンテナンスコストも増える。

アーキテクチャについて

私が知る中で、Goと比較的相性がよいのはオニオンアーキテクチャである。

ただし、その思想へ完全に準拠する必要はない。重要なのは、各パッケージの責務と依存方向を整理し、ドメインロジックがHTTPやデータベースなどの技術的な都合へ過度に依存しないようにすることだ。

本記事では、オニオンアーキテクチャそのものについては詳しく解説しない。必要であれば、別途調べてほしい。

レイヤーの責務

最低限、各レイヤーの責務を次のように分ける。

レイヤー責務
プレゼンテーション(presentationHTTPリクエストの受付、入力値の変換、レスポンスの生成
アプリケーション(usecaseユースケースの進行、ドメイン処理の組み合わせ、トランザクション境界の制御
ドメイン(domainエンティティ、値オブジェクト、ビジネスルール、リポジトリインターフェース
インフラストラクチャー(infrastructureデータベースや外部APIへのアクセス、リポジトリの実装
クエリ(cqrs/query参照処理の実行
リードモデル(cqrs/readmodel参照処理に必要なデータ構造やインターフェース

厳密な分類方法はリポジトリによって異なる。重要なのはディレクトリ名ではなく、それぞれの責務が混ざらないことである。

依存の方向

依存関係は以下を基本とする。

矢印は処理の流れではなく、パッケージの依存方向を表している。

flowchart TD
    Presentation["presentation"]
    Usecase["usecase"]
    Query["cqrs/query"]
    Domain["domain"]
    ReadModel["cqrs/readmodel"]
    Infrastructure["infrastructure"]

    Presentation --> Usecase
    Presentation --> Query
    Usecase --> Domain
    Query --> ReadModel
    Infrastructure --> Domain
    Infrastructure --> ReadModel

特に重要なのは、domainpresentationinfrastructureへ依存しないことである。

ただし、すべてのリポジトリでこの依存関係を厳密に守る必要はない。システムの規模、変更頻度、ドメインの複雑さ、求められる信頼性によって判断するべきだ。

複雑なドメインを長期間保守するシステムでは、依存方向を厳密に保つ価値がある。一方、小規模で寿命の短いシステムでは、多少の例外を許容した方が実装コストを抑えられる場合もある。

複数のドメインにまたがる参照処理

ドメイン単位でリポジトリを分割していると、複数のドメインをまたいだ検索や集計結果を、どこへ配置するべきか迷うことがある。

例えば、一覧画面を表示するために複数のテーブルをJOINし、複数のドメインにまたがるデータを取得するケースである。

このような参照処理を、無理に単一のドメインエンティティへ変換する必要はない。参照処理に特化したクエリとリードモデルを用意し、更新処理で利用するドメインモデルから分離する。

そのための設計として、CQRSを部分的に取り入れるのがよい。

CQRSを採用するといっても、更新用と参照用でデータベース自体を分割する必要はない。まずはコード上で更新処理と参照処理のモデルを分けるだけでもよい。

参照処理がドメインエンティティの構造へ過度に引きずられなくなるため、JOIN、集計、ページング、画面表示用のデータ取得を素直に実装できる。

CQRSをDDDへ組み込み、複数テーブルをJOINする参照処理をQuery Serviceとして分離する具体例については、TechDoctor開発者Blogの「DDDにCQRSをどう組み込むか~バックエンドアーキテクチャ設計時の考え方」が参考になる。

レイヤードアーキテクチャに対する批判

オニオンアーキテクチャを含むレイヤードアーキテクチャでは、レイヤーごとにDTOを定義し、境界を越えるたびに別の構造体へ詰め替える実装になりやすい。

これは、レイヤー間の疎結合を保つために必要な場合がある。一方で、構造体と変換処理が増え、コード量や認知負荷を高める原因にもなる。

この批判はもっともである。

すべての境界に機械的なDTO変換を設けるべきではない。境界を分けることで変更の影響を限定できる場合にのみ、専用の型を定義すればよい。同じ構造を維持することが明らかな場合は、型を共有する選択肢もある。

小規模なリポジトリで、形式だけを目的としてレイヤードアーキテクチャを導入する必要はない。

本記事で想定しているのは、ある程度の規模があり、ドメイン知識をコード上で明確に表現しながら、長期間保守する必要があるAPIサーバーである。

参考リポジトリ

参考として、GoのAPIのディレクトリ構成を表現したリポジトリを作成しました。

https://github.com/taako-502/go-api-template