TS の Backend 設計についてかんがえる
設計の話でよくあるようなレイヤードとかオニオンアーキテクチャとか、クリーンアーキテクチャの具体例(円形のやつ)とかのアーキテクチャがTSだとあまり上手くはまらない気がしていて言語化したい、あわよくば良い感じの形をさぐりたい
モヤってるとこ:
- この辺の設計論、OO(特に Java) の影響を色濃く受けてる感じがあって、FP 寄りのパラダイムも持ってたり、型システムも構造的部分型だったりの TS では上手く合わない?というかもっとよりやり方あるでしょな感じがしてる
- 具体的には...
- レイヤーごとの DTO が無駄に感じる
- DI(と依存性逆転)は素晴らしいけど、ビジネスロジックがデータフェッチの抽象にも依存せず単に引数から受ける形のほうがテスタビリティ高いしよくない?
- Backend って Req → Res への一覧の流れなのでどう工夫しても究極的に Transaction Script で、手続き的なまま UseCase にデータフェッチやらビジネスロジックやらを持っていっても認知負荷増えるだけに感じる
- Db の IO も Controller 層の責務にして(つまり UseCase を介さずにフェッチして)
- したがって Contoller はある程度 Fat で良くて
- ビジネスロジック自体は純粋関数で書かれていて、それらを Controller から読ぶ
- とかにすると筋が良い気がする
- データフェッチ、手続き的な書き方になるけどランタイム肥やしてもっと宣言的に書ける気がする
みたいなことをふわっと考えてる
思ってることは概ね書けたから思うところ追加であったら書いてく
いめーじ
こんなレイヤーになりがち
ビジネスロジックをデータフェッチ層に依存させない(Controller 層でフェッチして引数で渡す)
※
- Repository はふわっとデータフェッチの層くらいのイメージ、CQRS でも外でもなんでも
- UseCase は純粋関数(データフェッチ層に抽象でも依存しない)
ながれ
関数型で言う IO を前後に寄せて、中身は純粋関数で書こうぜの感じ
宣言的にデータフェッチする
特定のエンドポイントを叩いたときのデータフェッチ、ある程度宣言的に書ける気がしててそっちのケース
データ型の定義とかDTOとか
class でデータ型を宣言するの基本なしな感じする
言語化する
ふるまいとデータ構造をまとめて宣言したくないから(分離したい)
データ型T があるときに、T とセットで振る舞いを定義するのがメソッド
class User {
public constructor(
public firstName: string,
public lastName: string,
public optional?: string
) {}
public get fullName(): string {
return this.firstName + this.lastName
}
}
class User に定義されたメソッドはT の部分型
type UserOptionalRequired = {
firstName: string,
lastName: string,
optional: string
}
に対しても呼び出せてほしいけど class での実現が容易でない
- フロー解析でバリデーションしてもプロパティの直和型は絞り込まれない
- → バリデーションしたら絞り込まれたプロパティを持つ別の class を宣言して詰め替えの必要がある
- → 最悪、部分型のパターン数だけ宣言しないといけない
絞り込まれない話は一応、T を型引数として受けてプロパティが Tに依存するような形で class を定義すれば回避できないこともないけど、やる...?って感じ
逆に単なる DTO でふるまいを定義しないなら class で宣言、部分型は DTO の交差型で表現しても良いと思うけど
- class 構文で書かれていたらメソッドをはやせるので、メソッド生やさないようにしようねってルールやその理由を共有するコストがかかるのと
- 実害はないっちゃないかも(private な static メソッドとか生やしたらあるきもする、わからん)だけど、class の型に交差型とか取るの行儀的に避けたい(プレーンオブジェクトは type であって、class インスタンスは class の型であってほしい)
辺りを感じるので、データ型を宣言したいなら class は使わないで良いように思う
DI(と依存性逆転)は素晴らしいけど、ビジネスロジックがデータフェッチの抽象にも依存せず単に引数から受ける形のほうがテスタビリティ高いしよくない?
抽象に依存させることで、あとで DB 変えたくなったときに差し替えができるよねとか、テストでインメモリなDBを使おうねができたりとかはよくわかる
ただ、インフラストラクチャ層にビジネスロジックが依存したくないのは
- 事前条件としてテストデータを準備する必要があるから、テストケースのデータと期待するふるまいを書く場所が離れちゃう
- DB 遅いし
- ビジネスロジックの層でDB依存しちゃうと時間的に凝集になってしまう(意味的に凝集にしたい)
辺りの理由もあると思ってて、これらは DI じゃ解決されないから抽象でも依存させたくない
- データフェッチ自体を別の層でやる
- フェッチしたデータは引数から渡す
- → ビジネスロジックの単体テストはDBに依存しない
にしたい
宣言的にデータフェッチする
type UserWithFollowsData = {
user: User
follows: Follow[]
followUsers: User[]
}
type UserWithFollowsResponse = {
userWithFollows: UserWithFollow
}
const userWithFollowsController = defineController(
"/users/:id/with-follows/",
"GET",
)
.resolvers<UserWithFollowsData>({
user: async ({ pathParams }) => {
const maybeUser = await prismaClient.user.findUnique({
where: {
id,
},
})
if (maybeUser === null) {
throw new NotFoundError()
}
return maybeUser
},
follows: ({ prismaClient, pathParams }) =>
prismaClient.follow.findMany({
where: {
fromUserId: pathParams.id,
},
}),
followUsers: async ({ prismaClient }, { follows }) => {
const ids = await follows.then((follows) =>
follows.map(({ toUserId }) => toUserId)
)
return prismaClient.user.findMany({
where: {
id: {
in: ids,
},
},
})
},
})
.view(({ user, followUsers }): UserWithFollowsResponse => {
return {
userWithFollows: {
...user,
follows: followUsers,
},
}
})
簡単なのだと、みたいなインタフェースのイメージ
- その Controller でDB から CRUD するようなデータを宣言する
- 型でそれぞれのデータのフェッチ処理を強制的に書かせる(仮に resolver と呼ぶ)
- resolver には pending な Promise で他データが渡される、他データが必要な場合は await したデータを使って CRUD 処理を書く
- 本来は依存するものを先にフェッチして、...と時間凝集で書く必要があるんだけど、意味で凝集にできる
resolver の中身は他のエンドポイントと共通化したいケースもあると思うので、そこはそこで共有化すれば良さげ
もっとクエリっぽくもできる気がするけど、なら GraphQL で良くねになりそう
worker とか、batch とか他の副作用はどうするのって言われるとモニョる