GraphQL でフェッチした部分的なモデルのドメインロジックを TypeScript と Proxy で表現する試み
仕事で GraphQL を使ったプロダクトに DDD のエッセンスを取り入れたく、GraphQL で部分的にフェッチしたドメインモデルに堅牢な型付けを試みたので、まだまだ荒削りですが紹介します。
やりたいこと
このような GraphQL スキーマがあったとします。
type Person {
firstName: String!
lastName: String!
age: Int!
}
type Query {
person: Person!
}
この Person
に対して、以下ような 2 つのロジックを持たせたドメインモデルを定義したいです。
function getAnnotatedFullName(person: Person): string {
const suffix = isAdult(person) ? "" : " (未成年)"
return person.firstName + " " + person.lastName + suffix;
}
function isAdult(person: Person): boolean {
return person.age >= 20;
}
しかし、GraphQL では指定したフィールドのみをフェッチすることができるため、必ずしも firstName
や lastName
がフェッチされるとは限らず、getAnnotatedFullName
のようなロジックを実行できない場合があります。
単純な null チェックで済ませようとすると、ランタイムでのチェックになってしまい、実際には実行できないコードをデプロイしてしまう可能性があります。
このような問題をできる限り型レベルでチェックする試みです。
結論: class を Proxy でラップして This Parameter で型付け
TypeScript を騙す型付けをしつつ、Proxy でプロパティアクセスを操作することで挙動の整合性を保つ方法です。また、This Parameter を用いて、与えられたデータ構造がドメインロジックに必要なプロパティを満たしているか型レベルで検査しています。
これにより、以下のような検査が可能です。
class PersonDef<T = GqlPerson> extends DomainModelDefinition<T> {
static _fullModel: GqlPerson
public getAnnotatedFullName(this: DomainModelInstance<PersonDef, 'firstName' | 'lastName' | 'age'>): string {
const suffix = person.isAdult() ? " (成人)" : " (未成年)"
return this.firstName + ' ' + this.lastName + suffix;
}
public isAdult(this: DomainModelInstance<PersonDef, 'age'>): boolean {
return this.age >= 20;
}
}
const person = Person.create({
firstName: 'Yuku',
lastName: 'Kotani',
age: 20,
});
const personWithoutFirstName = Person.create({
lastName: 'Kotani',
age: 20,
});
// ok
person.getAnnotatedFullName();
person.isAdult();
// personWithoutFirstName には firstName がないのでエラー
personWithoutFirstName.getAnnotatedFullName();
完全なコードは TypeScript Playground を見てください。
使い方
具体的に、どのようにドメインモデルを定義して使用するか説明します。
まず、ドメインモデルが期待する完全なデータ構造(GqlPerson
)とドメインロジックを class として定義します。
type GqlPerson = {
firstName: string;
lastName: string;
age: number;
};
class PersonDef<T = GqlPerson> extends DomainModelDefinition<T> {
static _fullModel: GqlPerson
public getAnnotatedFullName(this: DomainModelInstance<PersonDef, 'firstName' | 'lastName' | 'age'>): string {
const suffix = person.isAdult() ? " (成人)" : " (未成年)"
return this.firstName + ' ' + this.lastName + suffix;
}
public isAdult(this: DomainModelInstance<PersonDef, 'age'>): boolean {
return this.age >= 20;
}
}
型パラメータ T
や static プロパティ _fullModel
に GqlPerson
型を渡すことで、TypeScript コンパイラに完全なデータ構造を伝えています。もっと TypeScript がうまい人ならどちらかは消せるかもです。
ドメインロジックとなるメソッドの This Parameter には DomainModelInstance
型を渡しています。これはドメインロジックのメソッドと部分的なデータ構造を合成する型です。第 2 引数に Pick と同じ要領でプロパティのキーを渡すことで、部分的なデータ構造を表します。
例えば isAdult
のパラメータになっている DomainModelInstance<PersonDef, 'age'>
は、大まかに(厳密には違うが)このような型になります。
{
age: number,
getAnnotatedFullName(this: DomainModelInstance<PersonDef, 'firstName' | 'lastName' | 'age'>): string,
isAdult(this: DomainModelInstance<PersonDef, 'age'>): boolean,
}
firstName
や lastName
は含まれません。getAnnotatedFullName
は含まれていますが、 This Parameter 制約を
満たさないため、呼ぼうとすると型エラーになります。
定義をしたら、これを元にドメインモデルのインスタンスを生成するコンストラクタを作ります。
const Person = createDomainModel(PersonDef);
const person = Person.create({
firstName: 'Yuku',
lastName: 'Kotani',
age: 20,
});
const personWithoutFirstName = Person.create({
lastName: 'Kotani',
age: 20,
});
createDomainModel
が返すオブジェクトは create
関数のみを持っています。create
関数は、先ほど定義した完全なデータ構造を DeepPartial
(Partial を孫以下のオブジェクトに再帰的に適用する型)で包んだ型を引数とします。つまり、ここでは DeepPartial<GqlPerson>
を受け取ります。
DeepPartial
については、例えば以下の 2 つの型が同じになります。
type First = DeepPartial<{
text: string;
obj: {
one?: number;
two: boolean;
};
}>;
type Second = {
text?: string;
obj?: {
one?: number;
two?: boolean;
}
}
そして、引数で受け取ったモデルを先程の DomainModelInstance
と同じ要領で、ドメインロジックと合成して戻り値とします。
そのため、以下の型 First
は、おおまかに Second
のようになり、This Parameter の制約を適用した検査が可能です。
const personWithoutFirstName = Person.create({
lastName: 'Kotani',
age: 20,
});
type First = typeof personWithoutFirstName;
type Second = {
lastName: string,
age: number,
getAnnotatedFullName(this: DomainModelInstance<PersonDef, 'firstName' | 'lastName' | 'age'>): string,
isAdult(this: DomainModelInstance<PersonDef, 'age'>): boolean,
};
// firstName がないので error
personWithoutFirstName.getAnnotatedFullName();
また、DomainModelInstance
型を使うことで、create
の結果として型を推論するだけでなく、部分的なドメインモデルに名前をつけて定義することもできます。
以下の PersonWithoutFirstName
は上記の Second
と同等の型になります。
type Person = DomainModelInstance<PersonDef>; // DomainModelInstance<PersonDef, 'firstName' | 'lastName' | 'age'> と同じ
type PersonWithoutFirstName = DomainModelInstance<PersonDef, 'lastName' | 'age'>;
仕組み
細かい部分は省いてコアとなる仕組みを解説します。詳細は型を読んでみてください。
まず、ドメインロジックを定義する基底クラスとなる DomainModelDefinition
です。
export class DomainModelDefinition<Model> {
constructor(public value: Model) {}
}
type _DomainModelDefinition<FullModel> = typeof DomainModelDefinition & {
_fullModel: FullModel;
};
DomainModelDefinition
のインスタンスは、元となるデータを value
プロパティに持ちます。また、クラス宣言だけではサブクラスが static プロパティを持つことを表せないため、DomainModelDefinition
コンストラクタとの Object 型の intersection で _DomainModelDefinition
を定義することで、_fullModel
という static プロパティを持つことを表しています。
次に、DomainModelDefinition
のサブクラスとして定義したドメインロジックから、コンストラクタを作成する createDomainModel
関数です。
const handler: ProxyHandler<DomainModelDefinition<any>> = {
get(instance, key, receiver) {
return Reflect.get(instance, key, receiver) || (instance.value as any)[key];
},
};
export function createDomainModel<Def extends _DomainModelDefinition<any>>(def: Def): DomainConstructor<Def> {
return {
create<Model extends DeepPartial<Def['_fullModel']>>(model: Model) {
const instance = new def(model);
return new Proxy(instance, handler) as unknown as DomainModelInstanceFromDef<Model, Def>;
},
};
}
create
関数だけを持つオブジェクトを返します。
先ほど _fullModel
を定義したため、Def['_fullModel']
で参照することができます。これを DeepPartial
で包むことで、任意の部分的なモデルを create
関数の引数として受け取ります。
そして、生成した DomainModelDefinition
のインスタンスを Proxy で包んで返すことで、プロパティへのアクセスの挙動が型に合うよう制御しています。
具体的には、まず Reflect.get() で通常通りプロパティへのアクセスを試みます。それが見つからなかった場合は、value
プロパティへフォールバックします。つまり、value
として保持したモデルに存在するプロパティを class のプロパティで上書きしていた場合は class を優先し、していない場合は value
にアクセスします。
Pros/Cons
Pros
- 部分的なデータ構造とドメインロジックの対応関係を型レベルで検査できる
- プロパティとドメインロジックを同じ名前空間に閉じ込められるため、補完の恩恵を受けやすい
- computed なプロパティもクラスのプロパティとして直感的に定義できる
Cons
- 複雑
- まだまだ対応できてないケースがたぶんあって、やりたいことをやるために複雑な型を改変していく必要がありそう
- ぜひフィードバックをもらえると嬉しいです
- Proxy によるオーバーヘッドが多少ある
- とはいえ Vue 3 も Proxy を使ってリアクティブ性を実現しているし、一般には無視できる範囲
やりたいことは実現できましたが、中身はかなり複雑(当社比)になってしまったので、メリットが複雑性に見合うかはプロジェクトによって慎重な判断が必要そうです。
まだできていないこと
- 型情報の単純化。現状では結果的なドメインモデルインスタンスの型が複雑で、エラーが出た時にわかりにくい。
-
Simplify
型とかを切り出さずに書くとシンプルになるかも?
-
-
DomainModelInstance
のインターフェイスに改良の余地あり。孫レベルでの Pick が実現できていない。
ここらへんを引き続き改良していきたいです。
(おまけ)採用しなかったパターン
型パズルほぼ未経験の状態から始めたため、かなり試行錯誤しました。その過程を雑に紹介しておきます。
1. よくあるクラスベースのドメインモデル
TypeScript でドメインモデルを定義すると言うと、まずはシンプルなクラスでの定義を思いつきます。
class Person {
constructor(
public firstName: string,
public lastName: string,
public age: number,
) {}
getFullName(): string {
return this.firstName + " " + this.lastName;
}
isAdult(): boolean {
return this.age >= 20;
}
}
const fullPerson = new Person("Yuku", "Kotani", 20);
fullPerson.getFullName();
fullPerson.isAdult();
これは一見うまく動くように見えますが、Person
の firstName
と lastName
を部分的にフェッチしたとします。
query {
person {
firstName
lastName
}
}
このとき、getFullName
は計算することができますが、isAdult
は age
プロパティを取得していないため計算することができません。
これをチェックするには、全てのプロパティを optional にしてランタイムでチェックするしかありません。
class Person {
constructor(
public firstName?: string,
public lastName?: string,
public age?: number,
) {}
getFullName(): string {
if (this.firstName == null || this.lastName == null) {
throw new Error("firstName or lastName is null");
}
return this.firstName + " " + this.lastName;
}
isAdult(): boolean {
if (this.age == null) {
throw new Error("age is null");
}
return this.age >= 20;
}
}
// GraphQL で firstName と lastName のみをフェッチしたモデル
const partialPerson = new Person("Yuku", "Kotani", undefined);
// no error
partialPerson.getFullName();
// runtime error
partialPerson.isAdult();
2. Pick を用いた型付け
Pick
を用いて、それぞれのロジックに必要なプロパティを持っているかチェックする方法です。
type Person = {
firstName: string;
lastName: string;
age: number;
}
// GraphQL で部分的にフェッチしたモデル
const fetchResult = {
firstName: "Yuku",
lastName: "Kotani",
}
function getFullName(person: Pick<Person, "firstName" | "lastName">): string {
return person.firstName + " " + person.lastName;
}
function isAdult(person: Pick<Person, "age">): boolean {
return person.age >= 20;
}
// no error
getFullName(fetchResult);
// error
isAdult(fetchResult);
この方法では、ドメインロジックを実行可能か型レベルでチェックできています。複雑性とメリットのバランスでいうと、これも現実的な選択肢の 1 つだと思います。
しかし、ドメインロジックがモデルとは別の名前空間(ここではグローバル)にあるため、ドメインモデルを利用する側から、どんなロジックがあるのかわかりにくいです。
Person
が getFullName
や isAdult
を持っていることは補完ではわからず、定義しているファイルを見に行く必要があります。
3. クラスで頑張る編
(コードは長くなるので貼りません、Playground を見てください)
Object.assign
でクラスのインスタンスにモデルのプロパティを注入するなどしてこねくり回しています。ただ、クラスのプロパティでは Index Signature が使えないため、型定義を上書きして騙しています。
その辺りをこねくり回していると、利用側のインタフェースが複雑になってしまい微妙でした。
4. クラスを再発明するんや!編
(コードは長くなるので貼りません、Playground を見てください)
Proxy を思いつく前です。モデルのオブジェクトにドメインロジックの関数オブジェクトを注入する、実質クラスの再発明にトライしていました。
しかし、クラスではメソッドからクラスの型を参照することができますが、オブジェクトだと循環参照になってしまい詰まっていました。
また、クラスのコンストラクタ的挙動を再現するのも大変でした。
おわりに
GraphQL クライアントのアプリケーションを TypeScript で安全に記述する試みを紹介しました。
現状ではかなり複雑になってしまいましたが、個人的には現在 Stage 1 の Extensions (旧 Bind Operator) に期待しています。これと This Parameter を組み合わせると、おまけパターン 2 くらいのシンプルさで同じようなことが実現できるかも。
また、今回は GraphQL を前提に解説しましたが、本質的には REST でも同じですし、サーバーサイドにおいても DB から部分的にデータを読んでドメインモデルを組み立てるなどができると思います。
もっと強力に検査する方法、シンプルにできるところなどあればぜひ教えてください。
Discussion