🧩

GraphQL でフェッチした部分的なモデルのドメインロジックを TypeScript と Proxy で表現する試み

21 min read

仕事で 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 では指定したフィールドのみをフェッチすることができるため、必ずしも firstNamelastName がフェッチされるとは限らず、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 プロパティ _fullModelGqlPerson 型を渡すことで、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,
}

firstNamelastName は含まれません。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 でドメインモデルを定義すると言うと、まずはシンプルなクラスでの定義を思いつきます。

TypeScript Playground

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();

これは一見うまく動くように見えますが、PersonfirstNamelastName を部分的にフェッチしたとします。

query {
  person {
    firstName
    lastName
  }
}

このとき、getFullName は計算することができますが、isAdultage プロパティを取得していないため計算することができません。

これをチェックするには、全てのプロパティを optional にしてランタイムでチェックするしかありません。

TypeScript Playground

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 を用いて、それぞれのロジックに必要なプロパティを持っているかチェックする方法です。

TypeScript Playground

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 つだと思います。
しかし、ドメインロジックがモデルとは別の名前空間(ここではグローバル)にあるため、ドメインモデルを利用する側から、どんなロジックがあるのかわかりにくいです。
PersongetFullNameisAdult を持っていることは補完ではわからず、定義しているファイルを見に行く必要があります。

3. クラスで頑張る編

TypeScript Playground

(コードは長くなるので貼りません、Playground を見てください)

Object.assign でクラスのインスタンスにモデルのプロパティを注入するなどしてこねくり回しています。ただ、クラスのプロパティでは Index Signature が使えないため、型定義を上書きして騙しています。
その辺りをこねくり回していると、利用側のインタフェースが複雑になってしまい微妙でした。

4. クラスを再発明するんや!編

TypeScript Playground

(コードは長くなるので貼りません、Playground を見てください)

Proxy を思いつく前です。モデルのオブジェクトにドメインロジックの関数オブジェクトを注入する、実質クラスの再発明にトライしていました。
しかし、クラスではメソッドからクラスの型を参照することができますが、オブジェクトだと循環参照になってしまい詰まっていました。
また、クラスのコンストラクタ的挙動を再現するのも大変でした。

おわりに

GraphQL クライアントのアプリケーションを TypeScript で安全に記述する試みを紹介しました。

現状ではかなり複雑になってしまいましたが、個人的には現在 Stage 1 の Extensions (旧 Bind Operator) に期待しています。これと This Parameter を組み合わせると、おまけパターン 2 くらいのシンプルさで同じようなことが実現できるかも。

また、今回は GraphQL を前提に解説しましたが、本質的には REST でも同じですし、サーバーサイドにおいても DB から部分的にデータを読んでドメインモデルを組み立てるなどができると思います。

もっと強力に検査する方法、シンプルにできるところなどあればぜひ教えてください。

Discussion

ログインするとコメントできます