LaBBaseプロダクト Advent Calendar 2022 の17日目担当、プロダクト部でエンジニアをしている @sho-kanamaru です!
今年は社内で DDD が大流行。
ほとんどのメンバーが DDD を触るのは初めてだったので、勉強会や輪読会をたくさん実施しました。
弊社が運営しているスカウト型の就活サービス「LabBase就職」のコア機能であるプロフィールを0から作り直すプロジェクトに配属されたので、良い機会だなと思いフロントエンドに DDD を導入してみました!
今回は、どのように導入したか、導入してみてわかったこと、メリット、デメリットなどをまとめたいと思います。
ディレクトリ構成
LabBase就職のフロントエンドは React + TypeScript で構成されているのですが、ディレクトリ構成は以下のようになっています。
- application
- infrastructure
- domain
- presentation
レイヤードアーキテクチャで構成されています。
レイヤードアーキテクチャに関しては、弊社CTOのテックブログで詳しく説明しているので、興味のある方は読んでみてください!
※ 今回 DDD を導入するにあたり、上記のレイヤードアーキテクチャと少し異なる構成で作っている部分もあります。
どのように DDD を導入したか
プロフィールの名前を更新する機能を例に説明します。
- 受け入れ基準
- 名前は必須項目
- 名前100文字以下
domain 層
エンティティと Vo を作成します。
export class Student {
private readonly _lastName: LastName;
private readonly _firstName: FirstName;
public get lastName(): LastName {
return this._lastName;
}
public get firstName(): FirstName {
return this._firstName;
}
private constructor(
lastName: string,
firstName: string
) {
this._lastName = LastName.new(lastName).unwrap();
this._firstName = FirstName.new(firstName).unwrap();
}
static factory = (studentDto: StudentDto) =>
new Student(
studentDto.lastName,
studentDto.firstName,
);
}
export class LastName {
value: string;
static MAX_LENGTH = 100;
private constructor(value: string) {
this.value = value;
}
static new(value: string): Result<LastName, DomainException> {
if (value.length === 0) {
return Result.Err(new DomainException('名前を入力してください。'));
}
if (value.length > LastName.MAX_LENGTH) {
return Result.Err(new DomainException(`名前が${this.MAX_LENGTH}文字を超えています。`));
}
return Result.Ok(new LastName(value));
}
}
このようにすることで、domain 層にビジネルルールを定義することができました。
ただし、すでにビジネスルールに違反したデータが存在する場合、上記の実装だと問題があります。例えば100文字を超える名前のデータがすでに存在する場合、fetch の際に上記の実装だとエラーが発生します。
これを避けるために、fetch のときはバリデーションなしでインスタンスを作成し、post や put のときはバリデーションありでインスタンスを作成する必要がありました。
export class LastName {
...
static newWithoutValidation(value: string): Result<LastNameEng, RuntimeError> {
return Result.Ok(new LastNameEng(value));
}
}
本来はアプリケーション上にはビジネスルールに違反するインスタンスは存在しないようにしたかったのですが、途中から DDD を導入するとこのように妥協せざるを得ない部分が発生することがやってみてわかりました。
usecase 層
名前を更新するというユースケースを作成します。
export class PutStudentUseCase implements UseCase {
constructor(
private _repository: Repo.IStudentRepository,
private builder: Build
) {}
async handle(studentForm: StudentForm): Promise<Result<Student, StudentRuntimeError>> {
// フォームのバリデーションチェック
const formResult = this.builder.handle(studentForm);
if (formResult.isErr()) {
return Result.Err(new FormValidateError(formResult.unwrapErr()));
}
// APIのエラーチェック
const writeResult = await this._repository.write(formResult.unwrap());
if (writeResult.isErr()) {
return Result.Err(writeResult.unwrapErr());
}
return Result.Ok(
Student.factory({
...writeResult.unwrap()
}),
);
}
}
// わかりやすいようにlastNameだけ
export class NameBuilder implements Build {
handle(studentForm: StudentForm) {
const lastName = LastName.new(studentForm.lastName);
if (lastName.isErr()) {
return Result.Err(lastName.unwrapErr());
}
const student = {
lastName: lastName.unwrap(),
};
return Result.Ok(student);
}
}
上記のように usecase 層でバリデーションチェックを行い、ビジネスルールに違反するものがあれば、API を叩かずに presentation 層にエラーを返すようにしています。
infrastructure 層
export class StudentApiDriver implements Driver {
private _api: ApiService;
constructor(api: ApiService) {
this._api = api;
}
async put(student: Student): Promise<Result<StudentDto, StudentRuntimeError>> {
try {
const response = await this._api.request('学生情報更新APIエンドポイント', this._api.METHOD.PUT, {
data: this.createParams(student),
});
return Result.Ok(toCamelCaseDeep<StudentDto>(response));
} catch (e) {
return Result.Err(new APIResponseError(e.response.status));
}
}
private createParams(student: Student) {
return {
last_name: student.lastName.value,
first_name: student.firstName.value,
};
}
}
infrastructure 層は単純に domain 層から受け取ったデータを整形して API を叩いているだけですね。
DDDを導入してみてわかったこと
メリット
各レイヤーの責務が明確になる
今まではビジネスロジック(名前は1文字以上100文字以下で書かれること)が domain 層に定義されていることもあれば、presentation 層の hooks の中に定義されていることもありました。
今回、DDD を導入したことで、ビジネスロジックは domain 層に定義することをチームで認識を合わせることができ、presentation 層が UI に関する責務だけを持つようなコードを書くことができました。
また、今まではいろいろな層にコードが分散してしまっていましたが、責務が明確になったことで、変更箇所の特定が容易になったり、ロジックを一部変更し忘れるようなことも起きこりにくくなったと思います。
コードのドキュメント性が高まる
コードと仕様書両方の運用ってなかなか大変ですよね。。
特に頻繁に仕様が変わることが多いスタートアップ企業ならなおさらです。
DDD を導入すると domain 層にビジネスロジックが書かれるので、domain 層を見れば仕様がわかります。もちろん細かな仕様やビジネスサイドへの共有を考えると、別途仕様書が必要な場合もあると思いますが、会社やプロダクトのフェーズによっては domain 層をドキュメント代わりにすることはありだなと個人的には思いますし、そのようなコードを目指したいですね。
ビジネスロジックのテストが書きやすい
上記に記載したように presentation 層の hooks にビジネスロジックがある場合、hooks からいろいろな外部モジュール(別の hooks や usecase など)を呼んでいたりする関係で非常にテストが書きにくいことがありました。
domain 層は別レイヤーに依存しない形で作っているので、テストが容易に書けます。
LastNameクラスに関してのテストも以下のように簡単に書けますね。
describe('LastNameモデルの正常系', () => {
test('LastNameモデルに変換できる', () => {
const lastName = '姓'.repeat(100);
const result = LastName.new(lastName);
expect(result.isOk()).toBeTruthy();
expect(result.unwrap().value).toEqual(lastName);
});
});
describe('LastNameモデルの異常系', () => {
test('姓が空のときはエラーになる', () => {
const lastName = '';
const result = LastName.new(lastName);
expect(result.isErr()).toBeTruthy();
expect(result.unwrapErr().message).toEqual('名前を入力してください。');
});
test('姓が101文字以上のときはエラーになる', () => {
const lastName = '姓'.repeat(101);
const result = LastName.new(lastName);
expect(result.isErr()).toBeTruthy();
expect(result.unwrapErr().message).toEqual(`名前が${LastName.MAX_LENGTH}文字を超えています。`);
});
});
デメリット
リアルタイム性のあるバリデーションが難しい
文字数のバリデーションなどの場合、フォームが入力されるたびにドメインモデルのインスタンスを作ることになるので、メモリを使いすぎるリスクがあります。
これを回避するためには、保存ボタンを押したタイミングやフォーカスが外れたタイミングにバリデーションをかけることになるので、若干UXが悪くなってしまいます。
途中から DDD を導入するのは難しい
すでにビジネルルールに違反したデータが存在する場合、厳密な DDD はできなくなるため、上記の newWithoutValidation のような妥協点がどこかで発生してしまいます。
ビジネスルールに違反したデータが入らないようなデータベース設計も大切だなと実感しました。
(名前が1文字以上100文字以下であるなら、型をvarchar(100)、NOT NULL制約をつけるなど)
domain 層への依存がレイヤーを跨いで発生しがち
presentation 層から(usecase 層を跨いで)domain 層へ依存することが発生しがちです。ビジネスロジックの流出をどこまで抑えるかであったり、フロントエンドデザインのレイヤーを跨ぐのは禁止というルールをどこまで守るかなど、チームで認識を合わせる必要があります。
おわりに
今回初めて DDD を勉強 & 導入してみて、メリット/デメリットがそれぞれある中で、いかにLabBaseやLabBase就職に合う設計を考えられるかが一番重要だなと改めて感じました。
設計思想には正解があるわけではなく、LabBaseメンバーのスキルやプロダクトのフェーズに合わせて、自分たちに一番合ったものを考える必要があります。
そして、それを考えるだけでなくメンバー全員で認識を合わせることができて初めてよりよいプロダクト開発ができると思うので、今回の DDD 導入をきっかけに引き続きチームメンバーで議論していきたいです!
次回の記事は最近カメラにハマっている @HHajimeW です!
よろしくお願いします!