概要
私の考える AI 時代のための web バックエンドアーキテクチャを説明します。
この記事を通して一番伝えたいことは、徹底的に抽象化を排除し、各レイヤーの役割・配置を明確にし、AI (初めてそのレポジトリを触る人) が理解しやすいアーキテクチャにすることです。
この記事では、Go を例に説明しています。
抽象化を徹底的に排除する
関数, メソッド, クラスの継承,... あらゆる抽象化をできる限り避けるような設計にしてください。
AI は優秀なプログラマーですが、そのレポジトリのコンテキストを一切知りません。AI にコードを書かせて、自分の意図通りのコードを吐いてくれないときに気づいたかもしれませんが、我々は気づかないうちにいたるところに抽象化をつくってしまいます。common ディレクトリを切って汎用的な関数を作ったり、二つのクラスの共通部分を継承元のクラスとして切り出したり...。誤解を恐れずに言うと、抽象化が多いコードは、AI (初めてそのレポジトリを触る人) にとって最悪のコードです。AI (初めてそのレポジトリを触る人) にとって最悪のコードと言いましたが、既存の開発者にとっても、可読性が低い場合が多いと思います。例えば、Rails で、継承元クラスと継承先クラスの境界があいまいになり、どっちにどのコードが書かれているのかを、抽象化されているはずのコードを読まないと把握できない状態は頻繁に起きます。内部を意識しなればいけない抽象化は、抽象化をする意味はありません。下手な抽象化はクラス間の依存関係を複雑にし、いつのまにか身動きの取れないコードを爆誕させています。 もちろん、適切な抽象化は可読性と保守性を高めますが、未来を見据えた適切な抽象化を行うのは本当に難しいです。
このような意見に対して、抽象化を排除したら、コードの量が増えまくって書くのに時間がかかってしまうのではないかと考える人もいるかもしれませんが、AI に書かせるので何も心配はいりません。今までは、抽象化をすることで実装スピードを上げてきましたが、時代は変わりました。抽象化はできる限り避け、アーキテクチャで縛ることを考えてください。クリーンアーキテクチャであれば、例えば、domain 層で意識するべきことは、port in 層のインターフェースを満たし、port out 層のインターフェースを使って永続化するということだけです。ワンレイヤーワンメソッドとして、そこからのメソッドの呼び出しが許可されるのは domain モデルのインスタンスメソッドに限定されます。また、全体を通して DRY が許可されるのは、domain モデルのインスタンスメソッドのみです。domain モデルは domain 層にしか登場しないので、必然的に、adapter in 層, adapter out 層では、それぞれのクラスは完全に切り離されます。
ライブラリ(OSS)
安易にライブラリを使用しないことです。前から言い続けてきましたが、gomock or moq, openapi, wire,... などのライブラリは導入する価値はないと思っています。
わざわざライブラリを導入すると、可読性が下がってしまいますし、自分で抽象化したものではないのでメンテコストも上がります。特にコミュニティが小さいライブラリは issue とかも少なくトラブルシューティングが難しくなります。また、メンテが終了したときの移行コストも大きいです。このくらい自分で書きましょう。
AI に書かせても、ライブラリを使ったコードよりも、ネイティブ(?)なコードの方が適切に処理してくれます。
sqlc
私は sqlc を使用しており、生の SQL を書いていますが、これはむしろ抽象化とは逆方向です。
私は動的クエリ、Lazy Load は、保守性が低いと考えています。どこでクエリが発行されるのかが明確ではなくなり、N+1 などの非効率なクエリに気づきにくいですし、ORM が複数チェーンされると、もはやどんなクエリが発行されるのかを把握することは不可能になります。トラブルシューティングとかでどうせ生の SQL を読むことになるんだったら、最初から抽象化なんてするなという話です。
sqlc は保守性を高めるために DRY を捨てているので冗長になりますが、AI がある今コーディング量が多いことは全く問題ではありません。
各レイヤーの役割・配置を明確に
それぞれのレイヤーの説明
各レイヤーの役割・配置を明確にすることで、AI がレポジトリ内からサンプルコードを見つける際に迷わなくなり、コードを生成する場所も間違えなくなります。
以下はクリーンアーキテクチャを例にしていますが、あくまでもレイヤーを分ける際の参考であって、クリーンアーキテクチャである必要はないと思います。クリーンアーキテクチャは、ドメインロジックが永続化の詳細に依存しない(安定が不安定に依存しない)ようにしたり、テストを書きやすくしたりできると言われていますが、前者に関しては、ドメインロジックと永続化を分けて考えて実装するとかほとんどないので、あまり意味を感じません。ただ、テストは圧倒的に書きやすく、ドメインロジックのテストなのに永続化の処理をするという、個人的に耐えがたいことを避けられる点は大きいです。
本レポジトリでは、以下の様にレイヤーを分けています。
- adapter (web/adapter)
- adapter in 層 (web/adapter/in)
- 外部からのリクエストを受け付けてレスポンスを返す層です。例えば、ユーザからのリクエストを受け付けてレスポンスを返します
- port in 層で定義されたインターフェースを利用します
- enum への変換など、ドメインロジックとは関係ない機械的にできる処理を担います
- adapter out 層 (web/adapter/out)
- 外部にリクエストを送ってレスポンスを受け取る層です
- port out 層で定義されたインターフェースを満たします
- enum への変換など、ドメインロジックとは関係ない機械的にできる処理を担います
- adapter in 層 (web/adapter/in)
- domain (service)層 (web/application/domain)
- ドメインロジックが書かれます
- 書き込みが発生する場合は commandservice パッケージ, 読み取りだけならば queryservice パッケージに分類します
- 複数の commandservice, queryservice を使いたい場合は、appcommandservice, appqueryservice に定義します
- port in 層で定義されたインターフェースを満たします
- port out 層で定義されたインターフェースを利用します
- model パッケージはドメインモデルです。ドメインロジックを処理するときは、このモデルのインスタンスメソッドで処理します。
- port (web/adapter)
- port in 層 (web/adapter/in)
- サービスのインターフェースを定義します
- web/adapter/in 内の 対応するファイルに配置しましょう(コードジャンプしたら、すぐに実装が見つかるように)
- port out 層 (web/adapter/out)
- adapter out 層のインターフェースを定義します
- web/adapter/out 内の 対応するファイルに配置しましょう(コードジャンプしたら、すぐに実装が見つかるように)
- port in 層 (web/adapter/in)
~/MyAppProject$ tree -a -L 9 -I ".git"
.
├── .claude
│ └── settings.local.json
├── .env
├── .github
│ ├── actionlint.yml
│ ├── actions
│ │ ├── build_all_images_using_cache
│ │ │ └── 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
│ ├── code_quality_checks.yml
│ ├── go_test.yml
│ ├── local_api_test.yml
│ └── messi_api_test.yml
├── .gitignore
├── .lefthook
│ └── pre-commit
│ ├── check_file_name.sh
│ ├── static_analysis.sh
│ └── static_analysis_format.sh
├── .vscode
│ └── settings.json
├── CLAUDE.md
├── README.md
├── api_test
├── docker-compose.ci.yml
├── docker-compose.yml
├── lefthook.yml
├── migrate
│ ├── Dockerfile
│ ├── entry_point_script
│ │ └── migrate.sh
│ ├── migrations
│ │ ├── 202412081520_create_users_table.down.sql
---
│ └── schema.sql
├── version_migrate.txt
├── version_web.txt
└── web
├── .golangci.yml
├── Dockerfile
├── adapter
│ ├── in
│ │ ├── apiinternal
│ │ │ ├── hello_apiinternal.go
---
│ │ ├── apiprivate
│ │ │ ├── admin_comment_patch_apiprivate.go
---
│ │ ├── middleware
│ │ │ ├── id_token_middleware.go
---
│ │ └── worker
│ │ ├── comment_sync_like_count_worker.go
---
│ └── out
│ ├── cognitoadapter
│ │ ├── user_list_users_cognitoadapter.go
---
│ ├── dbadapter
│ │ ├── article_create_dbadapter.go
---
│ ├── mediaconvertadapter
│ │ ├── mediaplatform_create_job_mediaconvertadapter.go
---
│ ├── redisadapter
│ │ ├── generic_bulk_increment_with_max_count_redisadapter.go
---
│ ├── s3adapter
│ │ ├── mediaplatform_get_content_type_s3adapter.go
---
│ ├── sesadapter
│ │ ├── notification_bulk_send_email_sesadapter.go
---
│ └── sqsadapter
│ ├── generic_batch_delete_message_sqsadapter.go
---
├── application
│ └── domain
│ ├── appcommandservice
│ │ └── .gitkeep
│ ├── appqueryservice
│ │ └── .gitkeep
│ ├── commandservice
│ │ ├── admin_comment_patch_commandservice.go
---
│ ├── model
│ │ ├── medium.go
---
│ └── queryservice
│ ├── article_get_queryservice.go
---
├── cmd
│ ├── webcommentlike
│ │ ├── dicontainerwebcommentlike
│ │ │ └── di_container_webcommentlike.go
│ │ ├── envconfigwebcommentlike
│ │ │ └── envconfig_webcommentlike.go
│ │ └── main.go
│ ├── webcommentview
│ │ ├── dicontainerwebcommentview
│ │ │ └── di_container_webcommentview.go
│ │ ├── envconfigwebcommentview
│ │ │ └── envconfig_webcommentview.go
│ │ └── main.go
│ ├── webmediajobresult
│ │ ├── dicontainerwebmediajobresult
│ │ │ └── di_container_webmediajobresult.go
│ │ ├── envconfigwebmediajobresult
│ │ │ └── envconfig_webmediajobresult.go
│ │ └── main.go
│ ├── webmediajobtrigger
│ │ ├── dicontainerwebmediajobtrigger
│ │ │ └── di_container_webmediajobtrigger.go
│ │ ├── envconfigwebmediajobtrigger
│ │ │ └── envconfig_webmediajobtrigger.go
│ │ └── main.go
│ └── webprimary
│ ├── dicontainerwebprimary
│ │ └── di_container_webprimary.go
│ ├── envconfigwebprimary
│ │ └── envconfig_webprimary.go
│ └── main.go
├── contextconfig
│ └── contextconfig.go
├── dbsetting
│ └── db_setting.go
├── enum
│ ├── articlecategory.go
---
├── go.mod
├── go.sum
├── router
│ └── router.go
├── sqlc.yml
├── sqlcgenerated
│ ├── command_article.sql.go
---
├── sqlscript
│ ├── command_article.sql
---
├── testenvconfig
│ └── testenvconfig.go
├── testutil
│ └── to_ptr.go
トランザクションをどこに貼るのか
トランザクションは、domain 層か、adapter out 層に貼ることになるでしょう。ほとんどの場合、domain 層に貼っていると思います。前者は、adapter out 層の処理(永続化)が domain 層に漏れ出てしまう一方で、後者は、domain 層の処理(ドメインロジック)が adapter out 層に漏れ出てしまいます。
私は、adapter out 層にトランザクションを貼ることを強く勧めます。なぜなら、永続化の処理を domain 層に漏れ出さないことは、DI の複雑性を下げ、domain 層のテストを書きやすくします。
テスト
domain 層のテストで db の DI (永続化の処理)を一切しないでください。domain 層のテストで永続化処理をしてしまったら、ドメインロジックのテストなのか、永続化処理のテストなのかわからなくなり、責任の境界が曖昧になります。
参考文献