📌

EntityのID発番についてTypeScriptで考える

14 min read 2

概要

修正(2021/11/23) コメントでご指摘いただいている通り、Union型と交差型を逆に記述していたので修正しました

DDDのEntityのID発番をRepositoryに任せたいとする。
UUIDなどで発番して良いのであればDomain層で発番してしまっても良いのだが、データの可視性の問題で連番で発番したいなどの要望はちょくちょくある。

こういった構成ではEntityのインスタンスが作られた瞬間はIDは空(null/undefined)で、Repository層に渡った後(例えばsaveメソッドに渡された後)にIDが発番されることになる。

そんなモデルを素直に型定義するなら、

export interface Entity{
   id?: string //idはstring | undefined
}

のような形になるが、このモデルの問題点はRepositoryからEntityを取り出した時にidundefinedでは無いことが(型定義上)保証されないことにある。

/**
* 疑似コード
*/

//直前でIDを指定して取得したのにも関わらず
const someEntity = someRepository.findById('some-id');

//Entity自体の存在確認をするのは良いが...
if(!someEntity) throw new Error('SomeEntity not found');

//IDの存在まで確認しなければいけないのがなんかダサい。。
if (someEntity.id) {
   //someEntity.idを使った何かの処理
}

理想的には、

  • Repositoryに渡すとき(saveなど)はIDが発番されていないくても良い
  • Repositoryから抜き出すとき(findAll, findByIdなど)はIDが発番されていることが保証されている

というモデルを実現したい。

ということで、TypeScriptのUnion型やジェネリクスをゴニョゴニョして実現して見ることにする。

実装

ID型をUnion型で定義する

IDには以下の2つの状態がある。

  1. IDが既に発番された状態
  2. IDがまだ発番されていない状態

この2つの状態をUnion型として定義する。

まずはIDが発番された状態。いわゆるValueObjectとして定義する。

export class GeneratedID {
  static of(value: string): GeneratedID {
    return new this(value);
  }

  readonly isGenerated = true as const;

  private constructor(public readonly value: string) {}

  toString(): string {
    return this.value;
  }

  toJSON(): string {
    return this.value;
  }

  equals(other: string | ID): boolean {
    if (typeof other === 'string') {
      return this.value === other;
    } else {
      return other.equals(this.value);
    }
  }
}

次にIDがまだ発番されていない状態。こちらは複数インスタンスが必要なわけではないのでSingletonでいいだろう。

export class NoneID {
  static readonly instance = new NoneID();

  readonly isGenerated = false as const;

  private constructor() {}

  toString(): never {
    throw new Error('NoneID cloud not to be converted to string');
  }

  toJSON(): never {
    throw new Error('NoneID cloud not to be converted to JSON');
  }

  equals(other: string | ID ): boolean {
    return typeof other !== 'string' && !other.isGenerated;
  }
}

そしてIDをこれらのUnion型として定義する。

export type ID = GeneratedID | NoneID;

それぞれの型にisGeneratedas constで定義しているため、このプロパティがtype guardの役割を果たす。

/**
* 疑似コード
*/

/**
* @param id ID型のインスタンス(この時点ではGeneratedIDかNoneIDか分からない)
*/
function someFunc(id: ID) {
   if (id.isGenerated) {
      //idがGeneratedIDであることが保証される
   } else {
      //idがNoneIDであることが保証される
   }
}

ついでに、IDを生成する(=GeneratedIDのインスタンスを作成する)関数を型定義しておこう。

export type GenerateIDFunction = () => Promise<GeneratedID>;

テストコード

import { GeneratedID, NoneID } from '../src/domain';

describe('GeneratedID', () => {
  test('発番済みのIDを表すValue Object', () => {
    const val = 'dummy-id-val';
    const id = GeneratedID.of(val);

    expect(id.isGenerated).toBeTruthy();
    expect(id.value).toBe(val);
    expect(id.toString()).toBe(val);
    expect(id.toJSON()).toBe(val);
    expect(`id is ${id}`).toBe(`id is ${val}`);
    expect(JSON.stringify({ id: id })).toBe(JSON.stringify({ id: val }));
    expect(id.equals(val)).toBeTruthy();
    expect(id.equals('not-equals')).toBeFalsy();
    expect(id.equals(GeneratedID.of(val))).toBeTruthy();
    expect(id.equals(GeneratedID.of('not-equals'))).toBeFalsy();
    expect(id.equals(NoneID.instance)).toBeFalsy();
  });
});

describe('NoneID', () => {
  test('未発番のIDを表すValue Object', () => {
    const id = NoneID.instance;

    expect(id.isGenerated).toBeFalsy();
    expect(() => id.toString()).toThrow();
    expect(() => id.toJSON()).toThrow();
    expect(id.equals('any-id')).toBeFalsy();
    expect(id.equals(GeneratedID.of('any-id'))).toBeFalsy();
    expect(id.equals(NoneID.instance)).toBeTruthy();
  });
});

Entityidをジェネリクス型で定義する

Entityのinterfaceを定義する。ポイントは、idの型をID型のジェネリクスとして定義するところである。

export interface Entity<IDType extends ID> {
  readonly id: IDType;
}

こうすることにより、実質以下の3つの型を定義したことになる。

  1. Entity<GeneratedID> ID発番済みのEntity
  2. Entity<NoneID> ID未発番のEntity
  3. Entity<ID> IDが発番済か未発番か不明なEntity

Union型をどう表現するかという問題はあるが、クラス図で表現するとこんな感じ。
クラス図

Entityの実装クラス図を定義する

ということで、Entityの実装クラスを定義しよう。ここでは例として、いわゆるゲストブック(誰でも書き込めるメッセージノート/掲示板)のメッセージをEntityとして定義する。

ここでもやはりポイントはidの型をジェネリクスにしておくことである。

export interface Reply {
  readonly text: string;
  readonly author: string;
  readonly posted: Date;
}

export class Message<IDType extends ID> implements Entity<IDType> {
  static new(text: string, author: string): Message<NoneID> {
    return new Message(
      NoneID.instance,
      text,
      author,
      new Date(),
      [],
    );
  }

  static of(
    id: GeneratedID,
    text: string,
    author: string,
    posted: Date,
    replies: Array<Reply>,
  ): Message<GeneratedID> {
    return new Message(
      id,
      text,
      author,
      posted,
      replies,
    );
  }

  private constructor(
    readonly id: IDType,
    readonly text: String,
    readonly author: String,
    readonly posted: Date,
    readonly replies: Array<Reply>,
  ) {}

  reply(text: string, author: string) {
    this.replies.push({
      text,
      author,
      posted: new Date(),
    });
  }

  isIDGenerated(): this is Message<GeneratedID> {
    return this.id.isGenerated;
  }

  async generateID(generate: GenerateIDFunction): Promise<Message<GeneratedID>> {
    if (this.isIDGenerated()) {
      return this;
    } else {
      const newId = await generate();
      return new Message(
        newId,
        this.text,
        this.author,
        this.posted,
        this.replies,
      );
    }
  }
}

しつこいようだが、ここでもMessage型を実質以下の3種類定義している。

  1. Message<GeneratedID> ID発番済のMessage
  2. Message<NoneID> ID未発番のMessage
  3. Message<ID> IDが発番済か未発番か不明なMessage

また、isIDGenerated()type guardとして実装しているため、Message<ID>型の実際の型を以下のように確定できる。

/**
* 疑似コード
*/

/**
* @param message: Message<ID>型なのでこの時点ではIDが発番済か未発番か未定
*/
function someFunc(message: Message<ID>) {
   if (message.isIDGenerated()) {
      // messageがMessage<GeneratedID>であること(ID発番済であること)が保証される
   } else {
      // messageがMessage<NoneID>であること(ID未発番であること)が保証される
   }
}

そして、Message<NoneID>generateID()メソッドを実行することにより、Message<GeneratedID>に変換することができる。

/**
* 疑似コード
*/

const generate: GenerateIDFunction = () => genereteIDFromDB();

//この時点ではMessage<NoneID>型
const newMessage = Message.new('こんにちは','山田太郎');
//generateIDすることでMessage<GeneratedID>型に変換される
const idGeneratedMessage = await newMessage.generateID(generate);

テストコード

import { GeneratedID, IMessageRepository, Message, NoneID, Reply } from '../src/domain';

describe('Message<GeneratedID>', () => {
  test('GeneratedID, text, author, posted, repliesからインスタンス化される', () => {
    const id = GeneratedID.of('dummy-generated-id');
    const text = 'こんにちは';
    const author = '山田太郎';
    const posted = new Date();
    const replies = [{ text: 'お久しぶり', author: '鈴木花子', posted: new Date() }];

    const message = Message.of(id, text, author, posted, replies);

    expect(message.isIDGenerated()).toBeTruthy();
    expect(message.id.equals(id)).toBeTruthy();
    expect(message.text).toBe(text);
    expect(message.author).toBe(author);
    expect(message.posted).toBe(posted);
    expect(message.replies).toEqual(replies);
  });

  test('replyメソッドで返信を追加できる', () => {
    const id = GeneratedID.of('dummy-generated-id');
    const text = 'こんにちは';
    const author = '山田太郎';
    const posted = new Date();
    const replies: Array<Reply> = [];

    const message = Message.of(id, text, author, posted, replies);

    expect(message.replies.length).toBe(0);

    message.reply('お久しぶり', '鈴木花子');
    message.reply('元気', '佐藤次郎');

    expect(message.replies.length).toBe(2);
  });

  test('generateIDメソッドは何もせず自身のインスタンスを返す', async() => {
    const id = GeneratedID.of('dummy-generated-id');
    const text = 'こんにちは';
    const author = '山田太郎';
    const posted = new Date();
    const replies = [{ text: 'お久しぶり', author: '鈴木花子', posted: new Date() }];

    const message = Message.of(id, text, author, posted, replies);

    const newGeneratedId = GeneratedID.of('new-generated-id');
    const generate = () => Promise.resolve(newGeneratedId);

    const generatedMessage = await message.generateID(generate);

    expect(generatedMessage.id.equals(newGeneratedId)).toBeFalsy();
    expect(generatedMessage.id.equals(id)).toBeTruthy();
  });
});

describe('Message<NoneID>', () => {
  test('text, authorからインスタンス化される', () => {
    const text = 'こんにちは';
    const author = '山田太郎';

    const message = Message.new(text, author);

    expect(message.id.isGenerated).toBeFalsy();
    expect(message.text).toBe(text);
    expect(message.author).toBe(author);
    expect(message.posted.getDate()).toBeCloseTo((new Date()).getDate());
    expect(message.replies.length).toBe(0);
    expect(message.isIDGenerated()).toBeFalsy();
  });

  test('replyメソッドで返信を追加できる', () => {
    const text = 'こんにちは';
    const author = '山田太郎';

    const message = Message.new(text, author);

    expect(message.replies.length).toBe(0);

    message.reply('お久しぶり', '鈴木花子');
    message.reply('元気', '佐藤次郎');

    expect(message.replies.length).toBe(2);
  });

  test('generateIDメソッドは新たにIDが発番されたMessage<GeneratedID>インスタンスを返す', async() => {
    const text = 'こんにちは';
    const author = '山田太郎';

    const message = Message.new(text, author);

    const newGeneratedId = GeneratedID.of('new-generated-id');
    const generate = () => Promise.resolve(newGeneratedId);

    const generatedMessage = await message.generateID(generate);

    expect(generatedMessage.id.equals(newGeneratedId)).toBeTruthy();
    expect(generatedMessage.text).toBe(message.text);
    expect(generatedMessage.author).toBe(message.author);
    expect(generatedMessage.posted).toBe(message.posted);
    expect(generatedMessage.replies.length).toBe(generatedMessage.replies.length);
    expect(generatedMessage.isIDGenerated).toBeTruthy();
  });
});

IMessageRepositoryを定義する

ということで、元々実現したかった

  • Repositoryに渡すとき(saveなど)はIDが発番されていないくても良い
  • Repositoryから抜き出すとき(findAll, findByIdなど)はIDが発番されていることが保証されている

というような挙動をするIMessageRepositoryを定義する。

export type FetchResult<E extends Entity<GeneratedID>> = {
  readonly results: Array<E>;
  readonly next: string | undefined;
};

export interface IMessageRepository{
  save(message: Message<ID>): Promise<void>;
  removeById(id: string): Promise<void>;
  findById(id: string): Promise<Message<GeneratedID> | undefined>;
  findAll(from: string | undefined, limit: number ): Promise<FetchResult<Message<GeneratedID>>>;
}

save()メソッドの引数はMessage<ID>としているため、Message<NoneID>(新規のメッセージの場合)とMessage<GeneratedID>(保存済みのメッセージの更新の場合)の両方を取ることができる。

一方、findById()findAll()の戻り値はMessage<GeneratedID>としているため、IDが発番済であることが保証されている。

このように、元々実現したかったモデルを実装することができた。

参考: IMessageRepositoryの実装クラス例

import { FetchResult, GeneratedID, ID, IMessageRepository, Message } from './domain';

export class InMemoryMessageRespository implements IMessageRepository {
  private lastIdNum: number = 0;

  constructor (private list: Array<Message<GeneratedID>> = []) {}

  private generateID(): GeneratedID {
    this.lastIdNum++;
    return GeneratedID.of(`MSG${('00000' + this.lastIdNum.toString()).slice(-5)}`);
  }

  async save(message: Message<ID>): Promise<void> {
    const idGeneratedMessage = message.isIDGenerated() ?
      message :
      await message.generateID(() => Promise.resolve(this.generateID()));
    this.list = [...this.list.filter(msg => !msg.id.equals(idGeneratedMessage.id)), idGeneratedMessage];
    return;
  }
  async removeById(id: string): Promise<void> {
    this.list = this.list.filter(msg => !msg.id.equals(id));
    return;
  }
  async findById(id: string): Promise<Message<GeneratedID> | undefined> {
    return this.list.find(msg => msg.id.equals(id));
  }
  async findAll(from: string | undefined, limit: number): Promise<FetchResult<Message<GeneratedID>>> {
    const gen = this.yieldMessages(from, limit);

    const results:Array<Message<GeneratedID>> = [];
    let nextId: string | undefined = undefined;

    let n = gen.next();
    while (!n.done) {
      const { msg, next } = n.value;
      results.push(msg);
      nextId = next;

      n = gen.next();
    }

    return {
      results,
      next: nextId,
    };
  }

  private *yieldMessages(from: string | undefined, limit: number) {
    const sortedList = this.list.sort((a, b) => b.posted.getDate() - a.posted.getDate());
    const fromIdx = !!from ? sortedList.findIndex(msg => msg.id.equals(from)) : 0;

    let idx = fromIdx >= 0 ? fromIdx : 0;
    let count = 1;

    while (idx < sortedList.length && count <= limit) {
      const msg = sortedList[idx];
      const next = idx + 1 < sortedList.length ? sortedList[idx + 1].id.value : undefined;
      yield { msg, next };
      idx++;
      count++;
    }
  }
}

まとめ

個人的にはDDDのベースとなっているオブジェクト指向は「型の設計を楽しむ設計パラダイム」だと思っている。もちろん、ただ楽しいだけではなく、自分の作りたいシステムのモデルをより厳密に定義することによってデバッグの大部分をIDEやコンパイラに任せることによって生産性を大きく上げることができる。

TypeScriptは今回使用したUnion型や今回は使用していない交差型など、型に関する独自の工夫が盛りだくさんで、より「型の設計」を楽しめる言語設計になっているように思う。

この記事が「型の設計を楽しむ」皆様のパターンの引き出しを1つにでもなれば幸いである。

Discussion

TypeScript は型の表現力が豊かなのでこういったドメインの表現をしやすくて良いですよね。

ID型を交差型で定義する
...
今回使用した交差型や今回は使用していないUnion型など

と言った表現がありましたが、実際にID型の章で使われていたのは

export type ID = GeneratedID | NoneID;

という | を使ったものなので、Intersection Types(交差型) ではなくUnion Types(合併型or共用体型) かと思われます。

https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html

また TypeScript は Structural Subtyping(構造的部分型) なので、ID型を作る代わりに、Entity を Intersection Types(交差型) にすることで、同様の

  • Repositoryに渡すとき(saveなど)はIDが発番されていないくても良い
  • Repositoryから抜き出すとき(findAll, findByIdなど)はIDが発番されていることが保証されている

という表現力を得る方法もあります。

具体的には以下のような感じです。

type HasId = { readonly id: string }
type UnregisteredMessage = {
  readonly text: string
  readonly author: string
  readonly posted: Date
  readonly replies: ReadonlyArray<Reply>
}
type Message = HasId & UnregisteredMessage

type MessageId = string

interface IMessageRepository {
  save(entity: Message | UnregisteredMessage): Promise<void>
  removeById(id: MessageId): Promise<void>
  findById(id: MessageId): Promise<Message | undefined>
  findAll(from: MessageId | undefined, limit: number ): Promise<FetchResult<Message>>
}

こちらの場合ですと、IDの wrap/unwrap 等のコストもかからなくなります。

save の実装などで Message | UnregisteredMessage を区別したい場合は、以下のような Type Guard を定義することで分岐することができます。

function isRegistered<T>(entity: (HasId & T) | T): entity is HasId & T {
  return 'id' in entity
}
const entity: Message | UnregisteredMessage = { text: '', author: '', posted: new Date, replies: [] }

if (isRegistered(entity)) {
  entity.id     // ここでは id 使える
} else {
  entity.author // ここでは id 使えない
}

ただこの方式にも以下のような代入が許されてしまう欠点があります。

const registered: Message = { ...}
const unregistered: UnregisteredMessage = registered

ご参考まで。

コメントありがとうございます!

Intersection Types(交差型) ではなくUnion Types(合併型or共用体型) かと思われます。

ご指摘ありがとうございます。
交差型とUnion型を逆に覚えておりました。。

返信投稿後、修正させていただきます。

また TypeScript は Structural Subtyping(構造的部分型) なので、ID型を作る代わりに、Entity を Intersection Types(交差型) にすることで、同様の

こちらもありがとうございます。
本文の方法に比べて非常にシンプルで大変実用的な方法だと思います。

一方で本文の方法にも実装として"重い"反面、IDをValue Objectとして表現するメリット(例えば、コンストラクタでバリデーションをかけることによって、ただのstringでは無いことを明示できるなど)もあるかと思いますので、記事を読まれた方はケースバイケースで使い分けていただければと思います。

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