はじめに
こんにちは、ENECHANGEエンジニアの木原です。
今回はGoによるクリーンアーキテクチャの実装を見ていて、疑問に感じたこと、そこから得た気づきを共有したいと思います。
Goの実装は以下のリポジトリを参考にしました。 github.com
結論
クリーンアーキテクチャとGoのインターフェースの原則は相反するように見えるが、そもそもの目的が異なる。
Goの暗黙的なインターフェースに従うことで、クリーンアーキテクチャの目的もある程度達成できる。
インターフェースを置くべき場所
クリーンアーキテクチャの場合

クリーンアーキテクチャといえば一番有名な図だと思います。 ここでは詳細な説明はしませんが、この図は「依存の方向は外から中心の方向に統一し、中心が外の変更の影響を受けないようにしましょう」と言っています。
これを前提にgo-clean-archリポジトリのREADMEにある概念図を見てみます。(黒い矢印は「自身がどこで使われているか」を指しています)

図から以下のことがわかります。
Domain/Model/EntityをRepository,Usecase/Service,Controller/Deliveryで使う(以下、それぞれDomain層,Repository層,Usecase層,Delivery層と呼ぶ)Repository層の実装をUsecase層で使うUsecase層の実装をDelivery層で使う
ここでクリーンアーキテクチャの視点から考えると、Usecase層はビジネスロジックを保持しているためRepository層, Delivery層よりも上位(内側)の概念になります。
よってRepository層, Delivery層がUsecase層に対して依存しているべきです。
クリーンアーキテクチャとしては、インターフェースをUsecase層に置くことで、Repository層, Delivery層からUsecase層に向けて依存の矢印が向くようにします。

Goの場合
Goのベストプラクティスとして「基本的にインターフェースは消費者側に置く」というものがあります。 つまり、インターフェースを置くべき場所は、具体的な実装を提供しているパッケージ内ではなく、それを使う側ということになります。
これを反映したのが以下の図です。クリーンアーキテクチャの場合と異なり、Service Interfaceを Delivery層で定義しています。

ここまでの話をまとめると、以下のようになります。
- クリーンアーキテクチャの視点では、インターフェースは上位の層に置くことで依存の方向をコントロールすべき
Usecase層の方がDelivery層よりも上位なので、Service InterfaceはUsecase層に置く
- Go言語の視点では、インターフェースは消費者側に置くべき
Usecase層の実装をDelivery層で使う(消費する)ので、Service InterfaceはDelivery層に置く
最初これらの違いを見た時、以下のような疑問を持ちました。
- クリーンアーキテクチャとGoでインターフェースを置くべき場所の方針が相反している
- Goの原則に従うと下位の層でインターフェースを持つことになり、クリーンアーキテクチャの視点からすると依存の向きが不適切なのではないか...?
ちなみにgo-clean-archリポジトリの実装は、Goの原則を守る形でService InterfaceをDelivery層に定義していました。
(なお、Usecase層とRepository層の関係では、Goの原則とクリーンアーキテクチャの原則が一致します。Usecase層がRepository層の「消費者」であり、かつ「上位レイヤー」でもあるためです。原則が衝突するのは、Usecase層とDelivery層の間だけです。)
二つの原則は目的が違う
一般的にどちらの原則を優先すべきなのか疑問に思ったため調査したところ、これらは相反するものではなく、そもそも二つの原則の目的が違うという結論に至りました。
クリーンアーキテクチャの目的
クリーンアーキテクチャの目的は「内側のビジネスロジックを外側の変更から守ること」です。
また、クリーンアーキテクチャの解説は明示的なインターフェースの文脈で展開されていると思います。(「このようなインターフェースを満たす実装を提供します」)
クリーンアーキテクチャは「何を抽象化するか」の決定権を内側のレイヤーで持つことで、依存の方向をコントロールすることに関心があります。
Goの暗黙的なインターフェースの目的
Goの暗黙的なインターフェースの目的は「不要な抽象化を避け、必要最小限のインターフェースを作ること」です。
また、Goではインターフェースは消費者側で定義して暗黙的に満たされます。(「このようなインターフェースを満たす実装を受け入れます」)
Goは「何を満たすべきか」の決定権を消費者側で持つことで、小さなインターフェースを作り疎結合にすることに関心があります。
| クリーンアーキテクチャ | Go | |
|---|---|---|
| インターフェースの性質 | 明示的(実装者が宣言) | 暗黙的(消費者が定義) |
| 定義する場所 | 上位レイヤー(内側) | 消費者側 |
| 目的 | 依存の方向を制御し、内側を守る | 必要最小限の結合にする |
| 関心事 | 何を抽象化するか | 何を満たせば十分か |
go-clean-archのコード
go-clean-archのコードを見てみると、Usecase層にも、Delivery層にも、import文にお互いのpackage名が存在しません。
Goではインターフェースは暗黙的に満たされるため、インターフェースを共有するためのimportは不要です。
これにより、Usecase層 と Delivery層 はお互いの存在を直接知らず、メソッドシグネチャという「契約」だけで繋がっています。
どちらかの内部実装が変わっても、シグネチャが同じである限り影響は波及しません。
// Usecase層 のimport import ( "context" "time" "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" "github.com/bxcodec/go-clean-arch/domain" ) // Delivery層 のimport import ( "context" "net/http" "strconv" "github.com/labstack/echo/v4" "github.com/sirupsen/logrus" validator "gopkg.in/go-playground/validator.v9" "github.com/bxcodec/go-clean-arch/domain" )
インターフェースを満たしたUsecase層の実装をDelivery層に渡すのは、main.goで行っています。(もし svc の実装がインターフェースを満たしていない場合、rest.NewArticleHandler(e, svc) の行でエラーになります。)
svc := article.NewService(articleRepo, authorRepo) rest.NewArticleHandler(e, svc)
クリーンアーキテクチャが依存の方向に気を配る理由は「中心が外の変更の影響を受けないようにしたい」からです。
Goでは暗黙的に満たされるインターフェースによって、パッケージ間を疎結合にすることができ、Goの原則に従うことでもクリーンアーキテクチャの目的をある程度達成できていると思いました。
まとめ
- クリーンアーキテクチャとGoでインターフェースの置き場に関する原則が相反するように感じたため調査した
- その結果、これらの原則は相反するものではなく、前提や目的が違うものであった
- クリーンアーキテクチャ
- 前提:明示的なインターフェース
- 目的:上位レイヤーでインターフェースを所有することで、ビジネスロジックを外側の変更から守る
- Go
- 前提:暗黙的なインターフェース
- 目的:消費者側でインターフェースを定義することで、小さなインターフェースで疎結合にする