🧅

TSでClean Architectureを真面目に考える

に公開

経緯

TSで、どうにか Clean Architecture(CA)を真面目につくれないだろうか?
と、思案していて、色々サンプルコードを書いていたのですが、
とりあえず、外形のイメージだけ作ってみたので公開

Clean Architecture を実装するときに、「依存方向を守る」だけでは不十分で、
「誰がインターフェースを定義し、誰がそれを使うか」 を明示的に縛れたほうがいいなと
今回はその思想を型レベルで表現してみました。

中身の話

そもそもの「CAとはなんぞや?」を考えた場合、一般的な説明でいくと

  • 玉ねぎ構造のあれ
  • 依存方法(外側から内側はOK)

になると思うのだけど、個人的にはそれだげがCAとは思えていなくて、もう少し深めに考えたほうがいいと思っている。
それをやる意味で考えたときに、各層の役割を改めて見渡すと

Enterprise Business Rules
Implements: Entities/Value Objects/Domain Services
Interface: Repository Interface

Application Business Rules
Implements: Use Cases
Interface: I/O Boundary / DTO(広義)

Interface Adapters
Implements: Controllers / Presenters / Gateways / Repository
Interface: Controller IN / Presenter OUT / Gateway low-layer IN

Frameworks and Drivers
Implements: Web Frameworks / UI Components / ORMs, HTTP Clients / Device Drivers, Cloud SDKs
Interface: Nothing

こんな感じになると思うのだけど「各層が何処にアクセスできるか」ってよりは、「各層のインタフェースを誰が定義するのか?」が重要だと思っています。

例えば Usecase は、純粋にApplicationとしての振る舞いを定義することと考えていますが、その場合、自分が受け取るデータ、自分が吐き出すデータについては、そのアプリケーションが想定する純粋な形であるべきで、ControllerやPresenterの都合などしったこっちゃありません。
なんなら、形が合わないなら、まわりが直せばいい ぐらいの意気込みです。

逆に、ConteollerやPresenterから見ると、立ち位置としては翻訳者(Adapter)の役割をまっとうする必要があります。
外部から受け取る形式を決めておき、その形式出来たものは、Applicationが要求した形式に作り変えるイメージですね。
ここ、正直ちょっと気持ち悪いんですが、Adapterというからには、自分に入力する情報形式が、外の形式に引っ張られていることになるので・・・・ とはいえ、役割は役割なので、抽象化した入力情報をGatewayの層で定義して、Usecaseの使う形に変換する Adapterとなるわけです。

ここで最初の話に戻るのですが、コードを書く際に外側から内側への依存を守るとして、定義する層と利用する層が入り混じる点があることに気がつくと思います。

  • Conteollerは、Usecaseが定義した入力を出力する
  • Presenterは、Usecaseが定義した出力を入力とする

みたいなケースです。
要は、自分が使うものを他人が定義するっていう構造がでてきてしまうのが、話をややこしくします。

というわけで、型レベルで、Interfaceに情報を与えて、許可する仕組みを考えてみました。

コード

https://github.com/risk/ts-playground/blob/main/src/caFramework/sandbox.ts

CanDepend

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L7-L12
定義比較用
使いみちとしては、「このレイヤーはこのインターフェースを使ってもいいか?」を静的型で検証する仕組みに使用します。

TypeScriptの extends 判定を利用して、I(配列)の中にLが含まれている場合にtrueを返す。
I(Layer[])の集団に、L(Layer)が含まれている場合
実体としては、Iの配列は、[]の中の型のUnionになるので、Lが含まれていればextendsで一致する仕組み。
エラーメッセージの出し方については、AIさんがこうしたらいいよ!って教えてくれたので採用。

Layer定義

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L14-L22
Layerの名称定義で、この名称をLayerに設定することで使用可・不可に使用します。
今回は層にしてしまったけど、ここは中の要素レベル(EntitiesとかUsecaseとか)にしたほうがよかったかもしれない。(Controller向けI/FをPresenterに使わせないとかもあるので)
とはいえ、定義変えればいいだけだから、今はそのまま。

Interfaceのポリシー

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L24-L26
このInterfaceをextendsすることで、使用可能なレイヤの定義を行います。
定義方法は、InterfacePolicyのLayersに対して、リテラル型の配列で与えるようにしています。

実際の定義例は、以下に。

Interfaceの定義例

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L28-L51
こんな感じで、誰に公開していいかを書く感じ。
文字列でそのままにしてるのは、リテラル型を使いたかった感じなので、Layersの値を使うなら typeofが必要になるはず・・・ いっそ、typeでリテラル型1個ずつ書いたほうが素直まである。

Layerのポリシー

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L53-L60
このInterfaceをレイヤにimplemenetsすることで、型の利用可・不可を判定させます。

Layerのベースクラス

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L62-L65
Layerの中に、許されない I or O のがある場合、この中の Check変数がエラーを拾います。

  ICheck: CanDepend<Level, I['usable']>
  OCheck: CanDepend<Level, O['usable']>

この辺が、チェックになりますが、変数に型を与えておくことで、あとでエラーチェックに利用します。

Layerの定義

https://github.com/risk/ts-playground/blob/ac2a5d3374d251622c750c87fcaeead31bcf55e0/src/caFramework/sandbox.ts#L67-L87
Layerのポリシーを定義すると、実行関数の実装を求められるとともに、Policyで指定したレイヤが、I と O に指定したInterfaceを利用できるかチェックしてくれます。
利用不可の場合は、LayerBaseの変数定義で引っ掛けられるため、ExtendsしているLayerは、エラーが発生するという仕組み。
正しい版とエラー版を両方書いておいたので、ご参考まで。

最後に

このコードは、まだサンプルコードでしか無いので、細かいところまで詰めきれていませんが、InterfaceにLayerの意味をもたせ、静的型チェックの時点で利用できないIFを弾き出す仕組みとしては、上手く機能するのではないかと思います。
とはいえ、大分煩雑なので、コレをやるぐらいならちゃんとルール守ればという話もあり、何が正解かはちょっと判断しづらいですが、強制力はある(エラーが出る)状態は作れそうなので、少しは安全にCAに取り組めるのかなぁと思う次第です。

Discussion