目的
graphql が好きで個人や小さなプロジェクトで apollo server + typescript +DDDで開発しました。まだまだ勉強中なのですが、gprahql serverとDDDの相性がとてもいいように感じています。そこで、これから graphql server を作る or リファクタリングしたいという方にぜひこの体験を共有したいと思いました。(特にフロントエンドで graphql を導入したいけど、なかなか共感が得られないよーという人の材料になれば嬉しいですw)
これは apollo server だけじゃなく、あらゆる言語で使えると思うのでよければ参考にしてみてください。(個人的には gqlgen でやってみたい)
おことわり
- 筆者はDDDのスペシャリストではありません
- そもそもDDDとはなんぞやとか概念ついては深堀りしません。ネット上や Qiita にも多くの方が素晴らしい説明をされているのでそちらを参考にしてくださいm(_ _)m
- コードのサンプルを載せますがこれがベストプラクティスとういわけではないです。DDD の実装では「間違いはあっても正解はない」という認識なので、「こうした方がいいんじゃね!?」ってあればドシドシコメントください
DDD + graphqlを使うと何がよかったのか
ドメインの設計がそのまま schema の設計に生きる
最初にgraphqlに挑戦した頃「これええやん!」と思って使っていましたが、一定の段階になると schema と resolver がぐちゃぐちゃになることが何回も発生しました。react でもコンポーネント設計指針がないとカオス化するのと同様に、schemaの設計指針がないとカオス化しちゃいます。
schema がカオス化する原因
- オブジェクトの分け方がわからなくなる
- 一貫性のないオブジェクトのまとめ方
- オブジェクトの関係が複雑
- 一貫性のない field の型
- UI または server の実装に依存している
これらの問題を解決するためには、この素晴らしいチュートリアルでドメインとオブジェクトの関係について語られています。
重要なところを抜粋すると
API の設計は、反復的な改善、実験、ビジネスドメインの十分な理解が求められる挑戦的なタスクです。
ルール #1: 詳細に取り掛かる前に、高いレベルでオブジェクトとそれらの関連性を考えることからはじめること。
ルール #2: 詳細実装を API 設計に含めないこと。
ルール #3: 詳細実装でも、ユーザインタフェースでも、あるいはレガシーな API でもなく、ビジネスドメインに従って API 設計を行うこと。
ルール #9: 歴史的経緯や詳細実装ではなく、意味のある概念に基づいてフィールド名を選ぶこと。
これらはほとんどDDDとレイヤードアーキテクチャで開発するときにも当てはまるルールではないでしょうか?つまりドメインのモデリングをすると、自然にschemaの大まかな設計も決まってくるはずです。
graphqlはpresentationalなレイヤーに置くように設計されている。
元facebookのLee Byronさんがこういっています。
graphql designed to be thin interface that sits the top all of your existing systems.
https://youtu.be/zVNrqo9XGOs
つまりgraphqlはpresentationalなレイヤーとして設計されています。
なのでもし既存にシステムに適応する場合でもドメインロジックを変更することなく導入できます。「grpahqlはクソだ!grpcにしたい!」となった場合でも対応できますね。
後に説明しますがgraphqlとビジネスロジックをつなげるcontrollerに当たるのがresolverになります。
ちなみにですが、上記のLeeさんのセッションが大好きで何回も見ていますw2016年のものですが、今でもすごく参考になります!
CQRSとの相性がいい
graphqlのoperationには
- Query
- Mutation
- Subscription
があります。subscriptionは特殊なのでおいといて、主にQueryとMutationをメインで使います。Queryは副作用を伴わないリクエストで、Mutationは副作用が伴うリクエストになります。
この考え方はCQRSと似ていますよね!
queryとmutationのモデルことを分けることで、複雑なクエリに対応しやすくなり、書き込みもqueryのことを心配することなくDDDの思想を反映できます。grpahqlではどうしてもqueryが複雑になりやすいので、CQRSを使うことでDDDにもgraphqlにもメリットがあります!
実装の例
https://github.com/takumiya081/graphql-server-typescript-ddd-example
例を作りました。イメージがしやすいようにUserを作って取得するだけのビジネスロジックがないものにしています。
レイヤー分けはclean architectureを元にしました。レイヤー分けと同時に、依存の方向も注意して作っています。
また、今回はmysqlを使っているのですがN+1問題に対処していません。適宜、dataloaderなどを使った遅延読み込みを実装してください。
ディレクトリ構成
/src
├─ core
├─ controller // resolverなど
├─ infra
| ├─ server
| └─ database
└─ modules
└─ user
├─ domain
| ├─ model
| ├─ readModel
| ├─ repository // DIP用
| └─ query // DIP用
├─ useCases
| └─ createUser
└─ interface // clean architectureのgatewayと同じ
├─ query
└─ repository
ポイント
useCaseで外側のレイヤーを使うために依存性逆転の法則を使う
useCase層でqueryやrepositoryを使うため、domainのレイヤーでそれぞれの抽象を定義します。
interface層とDatabase層を分ける必要があるのか?
メリットとしては、repositoryにルールを加えるときに対応しやすいです。
例えばUserを保存したときに、DBに保存してalgoliaにも追加するようになった場合を考えてみましょう。
interface層のUserRepository.create
でinfraで定義したdbとalgoliaを使えばよさそうです!
export class UserRepository implements IUserRepository {
private userDbModel: UserDBModel;
private userAlgoliaModel: UserAlgoliaModel;
constructor(userDbModel: UserDBModel) {
this.userDbModel = userDbModel;
}
public async create(user: User): Promise<void> {
await this.userAlgoliaModel.create(user);
await this.userDbModel.create(user);
}
}
デメリットとしては、同じinterfaceの型ができてしまうことです。
例えば、「いやいやalgoliaとかはpubsubでやるから大丈夫だよ」とする場合、こんな感じで不要なメソッドになってしまいます。
export class UserRepository implements IUserRepository {
private userDbModel: UserDBModel;
// これ意味があるのかな??
public async create(user: User): Promise<void> {
await this.userDbModel.create(user);
}
}
この場合、infraのdatabaseがIUserRepository
をimplementsしてもいいかもしれません。
resolverで依存を渡す場合にはcontextで渡す
apollo-server-testing
などを使ってresolverをテストしたいときは、テスト用の依存を渡せるようにしたいですよね!
今回は、contextで渡すことで実現しています。
// server.ts
const context: Context = {
dbModels: {
user: new UserDBModel(UserSequelizeModel),
},
};
const server = new ApolloServer({
typeDefs: importSchema('schema.graphql'),
resolvers: resolvers as any,
context: context,
});
// testClient.ts
import {ApolloServer} from 'apollo-server-express';
import {createTestClient} from 'apollo-server-testing';
export function createApolloTestClient(context: Context) {
return createTestClient(
new ApolloServer({
schema,
context,
}),
);
}
コツ
外部にapiを公開しない場合、schemaはUIは必要なものだけ公開するようにする
grpahqlのschemaは新しいものを追加するのは簡単ですが、修正したり消すことはとても複雑な流れになります。
「どうせ後で使うから、追加しておこう」という気持ちを抑えて必要になったとき初めてフィールドを追加するようにしています。
YAGNI!
ドメインとschemaの設計は時間を区切って考えよう!
ドメインとschemaの設計をすすめていくと、「もっといい設計があるのかもしれない」という気持ちに襲われますw
それを追い求めているうちに時間を相当無駄にしてしまいました。。。設計を始める前に時間を区切ってやればよかったと反省しています。
また、実装をしていくうちに最初に考えてたレイヤーではうまくいかないケースにたびたび遭遇しました。ガッツリ実装する前に、軽くプロトタイプを作ってイメージを固めた方がよかったと思っています。
絶対にDDDを使ったほうがいいとうわけではない
grpahql周辺のツールが今どんどん増えています。アプリケーションの規模と工数などを考えた場合、DDDとアーキテクチャを使って全て実装するよりもツールを使うほうがいい場面はもちろんあります。
とはいえドメインの概念は絶対に大事
ツールがどれだけ進化したとしても、schemaを考えるのはエンジニアです。schemaの設計が間違っていたら、どんなツールを使ってもどこかで辛くなると思います。ドメインの知識は、clientのストアーマネジメントにも適応できると考えているので、最低限ドメインの境界などは考えて損はないと思います。
最後に
いかがでしたでしょうか?まだまだ勉強中なので、もっといい方法があるとおもうのですがネットにもあまり記事がなかったので書いてみました。
自分はgraphqlを使ってからclient側は「もうRESTfulに戻れねー」となってしまっていますw
「けどサーバーサイド側にgraphql apiに対応してもらうの気が引ける。。。」という方は、DDDを使ってBFFを立ててみるのも一つの手だと思います!
記事を書くにあたって参考にさせてもらいました。ありがとうございます。
https://qiita.com/APPLE4869/items/d210ddc2cb1bfeea9338
https://www.slideshare.net/koichiromatsuoka/ddd-x-cqrs-orm
https://khalilstemmler.com/