EntityのID発番についてTypeScriptで考える
概要
修正(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を取り出した時にid
がundefined
では無いことが(型定義上)保証されないことにある。
/**
* 疑似コード
*/
//直前で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つの状態がある。
- IDが既に発番された状態
- 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;
それぞれの型にisGenerated
をas 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();
});
});
Entity
のid
をジェネリクス型で定義する
Entity
のinterfaceを定義する。ポイントは、id
の型をID
型のジェネリクスとして定義するところである。
export interface Entity<IDType extends ID> {
readonly id: IDType;
}
こうすることにより、実質以下の3つの型を定義したことになる。
-
Entity<GeneratedID>
ID発番済みのEntity
-
Entity<NoneID>
ID未発番のEntity
-
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種類定義している。
-
Message<GeneratedID>
ID発番済のMessage
-
Message<NoneID>
ID未発番のMessage
-
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型の章で使われていたのは
という
|
を使ったものなので、Intersection Types(交差型) ではなくUnion Types(合併型or共用体型) かと思われます。また TypeScript は Structural Subtyping(構造的部分型) なので、ID型を作る代わりに、Entity を Intersection Types(交差型) にすることで、同様の
という表現力を得る方法もあります。
具体的には以下のような感じです。
こちらの場合ですと、IDの wrap/unwrap 等のコストもかからなくなります。
save
の実装などでMessage | UnregisteredMessage
を区別したい場合は、以下のような Type Guard を定義することで分岐することができます。ただこの方式にも以下のような代入が許されてしまう欠点があります。
ご参考まで。
コメントありがとうございます!
ご指摘ありがとうございます。
交差型とUnion型を逆に覚えておりました。。
返信投稿後、修正させていただきます。
こちらもありがとうございます。
本文の方法に比べて非常にシンプルで大変実用的な方法だと思います。
一方で本文の方法にも実装として"重い"反面、
ID
をValue Objectとして表現するメリット(例えば、コンストラクタでバリデーションをかけることによって、ただのstringでは無いこと
を明示できるなど)もあるかと思いますので、記事を読まれた方はケースバイケースで使い分けていただければと思います。