概要
クリーン(ヘキサゴナル)アーキテクチャを採用している私のプロジェクトを見ながら、ヘキサゴナルアーキテクチャの概要を理解し、アーキテクチャを設計する上で私が重要だと思うことを学ぶことが目的です。
ディレクトリ構成とレイヤーの説明
ヘキサゴナルアーキテクチャでは以下のレイヤーを基本とします。
- adapter (web/adapter)
- adpter in 層 (web/adapter/in)
- 外部からのリクエストを受け付けてレスポンスを返す層です。例えば、ユーザからのリクエストを受け付けてレスポンスを返します
- 本プロジェクトでは、apiinternal, apiprviate パッケージが該当します
- port in 層で定義されたインターフェースを利用します
- enum への変換など、ドメインロジックとは関係ない機械的にできる処理を担います
- adapter out 層 (web/adapter/out)
- 外部にリクエストを送ってレスポンスを受け取る層です
- 本プロジェクトでは、cognito(cognito にアクセス), repository(DBにアクセス) パッケージが該当します
- port out 層で定義されたインターフェースを満たします
- enum への変換など、ドメインロジックとは関係ない機械的にできる処理を担います
- 本プロジェクトは enum 型は int16 でDBに保存されます
- adpter in 層 (web/adapter/in)
- domain (service)層 (web/application/domain)
- ドメインロジックが書かれます
- 書き込みが発生する場合は commandservice パッケージ, 読み取りだけならば queryservice パッケージに分類します
- 複数の commandservice, queryservice を使いたい場合は、appcommandservice, appqueryservice に定義します
- port in 層で定義されたインターフェースを満たします
- port out 層で定義されたインターフェースを利用します
- model パッケージはドメインモデルです。ドメインロジックを処理するときは、このモデルを中心にして処理します。ただの CRUD 操作の場合、わざわざドメインモデルを使う必要はないですが、ドメインロジックが重い時は使いましょう
- port (web/application/port)
- port in 層 (web/application/port/ingressport)
- サービスのインターフェースを定義します
- 本プロジェクトでは、ingressport パッケージが該当します
- port out 層 (web/application/port/egressport)
- adapter out 層のインターフェースを定義します
- 本プロジェクトでは、egressport パッケージが該当します
- port in 層 (web/application/port/ingressport)
~/GoBackend$ tree -a -L 9 -I ".git"
.
├── .env
├── .github
│ ├── actions
│ │ ├── build_all_images_using_cache_and_up
│ │ │ └── action.yml
│ │ └── install_tools
│ │ └── action.yml
│ ├── dependabot.yml
│ └── workflows
│ ├── build_push_and_tag_release_and_create_pr.yml
│ ├── cache_docker_image_to_default_branch.yml
│ ├── exec_actionlint.yml
│ ├── go_test.yml
│ ├── pre-commit_pre-push_check.yml
│ └── static_analysis.yml
├── .gitignore
├── .lefthook
│ ├── pre-commit
│ │ └── required_command_check.sh
│ └── pre-push
│ └── test_push.sh
├── README.md
├── docker
│ ├── migrate
│ │ └── Dockerfile
│ └── web
│ └── Dockerfile
├── docker-compose.yml
├── lefthook.yml
├── migrate
│ ├── entry_point_script
│ │ └── migrate.sh
│ ├── migrations
│ │ ├── 202412081520_create_users_table.down.sql
│ │ ├── 202412081520_create_users_table.up.sql
---
│ └── schema.sql
├── version_migrate.txt
├── version_web.txt
└── web
├── .air.toml
├── .golangci.yml
├── adapter
│ ├── in
│ │ ├── apiinternal
│ │ │ ├── story_video_patch_by_mediaconvert_controller.go
│ │ │ ├── story_video_patch_by_mediaconvert_controller_test.go
---
│ │ └── apiprivate
│ │ ├── article_create_controller.go
│ │ ├── article_create_controller_test.go
---
│ └── out
│ ├── cognito
│ │ ├── auth_confirm_forgot_password_cognito.go
---
│ └── repository
│ ├── article_create_repository.go
│ ├── article_create_repository_test.go
---
├── application
│ ├── domain
│ │ ├── appcommandservice
│ │ │ └── .gitkeep
│ │ ├── appqueryservice
│ │ │ └── .gitkeep
│ │ ├── commandservice
│ │ │ ├── article_create_commandservice.go
│ │ │ ├── article_create_commandservice_test.go
---
│ │ ├── model
│ │ │ └── story_video.go
│ │ └── queryservice
│ │ ├── article_get_queryservice.go
│ │ ├── article_get_queryservice_test.go
---
│ └── port
│ ├── egressport
│ │ ├── article_create_egressport.go
---
│ └── ingressport
│ ├── article_create_ingressport.go
---
├── cmd
│ └── GoBackend
│ └── main.go
├── config
│ └── config.go
├── container
│ └── container.go
├── contextconfig
│ └── contextconfig.go
├── dbsetting
│ └── dbsetting.go
├── enum
│ ├── articlecategory.go
│ ├── articlecategory_marshal.go
│ ├── articlecategory_string.go
---
├── go.mod
├── go.sum
├── middleware
│ ├── id_token_middleware.go
---
├── router
│ └── router.go
├── sqlc.yml
├── sqlcgenerated
│ ├── command_article.sql.go
---
│ ├── db.go
│ ├── models.go
│ ├── query_article.sql.go
---
├── sqlnullconverter
│ └── convert_pointer_to_sql_null.go
├── sqlscript
│ ├── command_article.sql
---
│ ├── query_article.sql
---
└── tmp
├── ---
---
トランザクションをどこに貼るのか
ヘキサゴナルアーキテクチャで、トランザクションを貼る層は定まっていません。
domain 層か、adapter out 層に貼ることになるでしょう。前者は、adapter out 層の処理(永続化)が domain 層に漏れ出てしまう一方で、後者は、domain 層の処理(ドメインロジック)が adapter out 層に漏れ出てしまいます。
私は、adapter out 層にトランザクションを貼ることを強く勧めます。なぜなら、永続化の処理を domain 層に漏れ出さないことは、依存注入の複雑性を下げ、domain 層のテストを書きやすくし、非効率なクエリの防止になります。サービス層に db の依存が注入されてしまうと、N+1 などの非効率なクエリの温床となります。
抽象化で縛るな、アーキテクチャで縛れ
ここで言う抽象化は、いたるところで使われることが想定されている関数・メソッドや、いたるところで継承されることが想定されているクラスを指しています。
Rails とかで、継承元クラスと継承先クラスの境界があいまいになり、可読性と保守性を損なっているコードを見たことはあるでしょう。継承元クラスの抽象化に失敗すると、身動きが取れなくなってしまっています。
内部を意識しなればいけない抽象化は、抽象化をする意味はありません。そして、抽象化はトラブルシューティングを難しくします。
開発者の実装スピードを速くするために導入した独自の抽象化が、ハイコンテキストなコードを生み、むしろ実装スピードを遅くしてしまっていることは多々あるでしょう。
独自の抽象化はできる限り避け、アーキテクチャで縛ることを考えてください。ヘキサゴナルアーキテクチャであれば、例えば、domain 層で意識するべきことは、port in 層のインターフェースを満たし、port out 層のインターフェースを使って永続化するということだけです。
ドメインの境界は増えたら切る
ヘキサゴナルアーキテクチャは、ドメインの境界をどのように扱うのかは決めていません。現在のディレクトリ構成はドメイン境界でパッケージを切っていませんが、ファイルが増えてきたら、ドメイン境界でパッケージを切ってもいいかもしれません。
最初の 1 割に徹底的にこだわる
チーム開発を経験している人なら分かると思いますが、Webバックエンドのコードは横展開の繰り返しであり、既存のコードがコピペして使われます。
初期段階のコードの保守性が低ければ、その後のコードも低くなります。もちろん時間の制約もあると思いますが、できるならば、初期段階で負債に気づいたら早めにリファクタリングしましょう。
仕様を落とす
技術的に難しいと感じたとき、本当に実装する価値があるのか考えましょう。例えば、golang では、リクエストの 明示的な null と undefined を区別するのは少し難しいです。その労力に見合う価値を本当に生み出すのか検討する必要があります。少し難しくても一回書いてしまえば使いまわせると思うかもしれませんが、保守コストを軽視してはいけません。そのコードを保守するのはおそらくあなたではありません。できる限りシンプルにしましょう
常にレイヤーを中心にコードを書く
コードを書くときは、以下のレイヤーを中心に考えます。
例えば、domain (service)層のメインのメソッドがある関数を呼び出すとき、その呼び出した関数がさらに別の関数を呼び出すことを原則として禁止します。domain (service)層のメインのメソッドがある関数を呼び出したら、一旦メインのメソッドに戻ってきて、メインのメソッドからさらに別の関数を呼び出すようにします。こうすることで、コードを読むときに、常にレイヤーが軸となり、ロジックが明確になります。
- adapter
-
adpter in 層 (web/adapter/in)
- format request 層
- format response 層
- adapter out 層 (web/adapter/out)
-
adpter in 層 (web/adapter/in)
- domain (service)層 (web/application/domain)
Don't DRY
迷ったら DRY は避けてください。予言しますが、迷った末に DRY にした箇所は、特定のクラスのための実装が入り込み抽象化に失敗します。そして、いつの間にか身動きが取れない状況になります。
関数はできる限り小さくし、迷ったら DRY は避けるようにすれば、後で DRY に書くことは容易です。
ライブラリ
sqlc
私は動的クエリ、Lazy Load は、保守性が低いと考えています。どこでクエリが発行されるのかが明確ではなくなり、N+1 などの非効率なクエリに気づきにくいからです。また、ORM が複数チェーンされると、可動性が大幅に下がります。
私はこの問題に対処するために、sqlc を採用しました。クエリの発行位置が明確になり、生のSQLを読めるので可読性が大幅に上がります。
ただ、もちろん、ワンサイドゲームではありません。sqlc は保守性を高めるために DRY を捨てているので、冗長になります。コード量が増えるので、初期段階では開発スピードが少し遅くなるかもしれません。
車輪の再発明をすることをためらわない
ライブラリの欠点は以下のようにいくつかありますが、それを超えるメリットがあるのか常に考えましょう
- 抽象化によりトラブルシューティングを複雑にしてしまいます。コミュニティが小さければ、なおさらトラブルシューティングは難しくなります
- メンテが終わった時に移行コストが発生してしまいます
- 学習コスト、メンテコストが高くつく場合があります
車輪の再発明と呼んでいいものか分かりませんが、以下に例を挙げます
gomock or moq は必要か?
例えば、domain(サービス)層のテストを書くときに、gomock や moq を使うプロジェクトもあると思いますが、私は推奨しません。
本プロジェクトの domain(サービス)層のテストでは、mock を自作しています。ライブラリを導入して得られるメリットに対して、上述したデメリットが大きいと判断したからです。
wire は必要か?
上述した理由により、私はライブラリを導入せずに、自作コードで依存注入しています。
テスト
domain層 のテスト
domain 層のテストで db の依存注入をしないでください。つまり、永続化の処理を一切しないでください。domain 層のテストで永続化処理をしてしまったら、ドメインロジックのテストなのか、永続化処理のテストなのかわからなくなってしまいます。
テストも保守対象
テストケースを増やすことを目的にしないでください。テストを増やせば増やすほど動作は保証されるかもしれませんが、メンテコストが増えてしまいます。仕様変更で大量のテストが落ちたとき、あなたは修正しなければいけません。
参考文献