こんにちは、TECH PLAY Academyでメンターをしている久保です。本記事では、受講者のスキルを評価するアセスメントサービスを開発する際に採用している ドメイン駆動設計(以下DDD: Domain-Driven Design) について解説します。
1. TECH PLAY Academyのアセスメントサービスとは?
TECH PLAY Academyでは、企業のエンジニア向けにプログラミング研修を提供しています。受講者のスキルを評価するために開発したアセスメントサービスでは、以下の機能を提供しています。
- 研修課題の進捗管理
- アセスメント課題の成績登録・閲覧
- 企業や役割ごとの権限管理
本サービスでは、業務領域を中心に設計するためにDDDを採用したというのが大目的ではなく、あくまでDDDのエンティティや値オブジェクト、カプセル化を応用した集約の概念を利用することで、保存するオブジェクトが正しくバリデーションされることを狙いとしました。これにより、データの整合性を保つだけでなく、セキュリティの観点からも安全性を高めることができます。本記事では、Next.jsとPrismaを使用したアプリケーション開発におけるDDDの概念の一部であるエンティティや値オブジェクトの実装方法を紹介します。
2. DDDの基本概念
2.1 エンティティと値オブジェクト
エンティティ(Entity):IDで識別される、変化するオブジェクト
エンティティは、一意の識別子(ID)を持ち、時間とともに状態が変化するオブジェクトです。例えば「ユーザー」や「注文」などがエンティティに該当します。
エンティティの目的
- IDを持つことで同一性を保証する
- 時間経過による状態変化を表現する
エンティティのプロパティに値オブジェクトを使用する理由
エンティティのプロパティに単純な string
や number
を使用すると、プロジェクト特有の制約を表現しづらくなります。
例えば CourseId
を単なる string
として扱うと、誤った値が代入されても検証できません。
そのため、プロジェクト固有のルールを持たせるために、値オブジェクトを使用し、適切なバリデーションが強制的に適用される状況を作り出すことを目的としています。
実装例(エンティティの定義)
export class Course {
constructor(
private readonly id: CourseId,
private readonly title: CourseTitle,
private readonly companyId: CompanyId,
private readonly details: CourseDetail,
private readonly goals: CourseGoal[]
) {}
public static createFromDto(dto: CourseDto): Result<Course, ValidationError> {
return Result.combineWithAllErrors([
CourseId.create(dto.id),
CourseTitle.create(dto.title),
CompanyId.create(dto.companyId),
CourseDetail.create(dto.details),
this.createGoalsFromDto(dto.goals),
])
.mapErr((errors) => ValidationError.combine(errors))
.map(([id, title, companyId, detail, goals]) => new Course(id, title, companyId, detail, goals));
}
}
値オブジェクト(Value Object):不変のデータを表現する
値オブジェクトは、IDではなくその値自体が重要なオブジェクトです。例えば「住所」や「金額」などが該当し、常に不変(イミュータブル)であるべきです。
値オブジェクトの目的
- 値の意味と検証ルールをカプセル化する
- 型安全性を高める
- プリミティブ型の乱用を防ぐ
実装例(値オブジェクトの定義)
export class CourseId extends Id {
private constructor(value: string) {
super(value);
}
public static create(value: string): Result<CourseId, CourseValidationError> {
const result = this.schema.safeParse(value); // バリデーションの実施
return result.success ? ok(new CourseId(value)) : err(CourseValidationError.fromZodError(result.error));
}
public static generate(): CourseId {
return new CourseId(this.generateNewUUID());
}
}
2.2 集約(Aggregate)とルート
集約とは、関連するエンティティと値オブジェクトをまとめ、一貫性を保つ単位です。集約のエントリーポイントとなるオブジェクトを 集約ルート と呼びます。オブジェクト指向のカプセル化の概念を利用することで、外部から直接エンティティや値オブジェクトを操作されることを防ぎ、不正なデータの変更や整合性の欠如を回避できます。
また、関連するオブジェクトを一元的に管理することで、データの整合性を確保しやすくなり、ドメインルールの適用が容易になります。
もし集約を適切に設計せず、エンティティや値オブジェクトが自由に変更できる状態だと、ビジネスロジックがシステム全体に散らばってしまい、一貫性の維持が困難になります。
その結果、異なる箇所で矛盾したルールが適用されるリスクが高まり、システムの保守性が著しく低下してしまいます。
集約を適切に設計することで、変更を集約ルート経由に統一し、ロジックを整理しながら安全にデータを管理できるようになります。
集約の目的
- データの一貫性を保証する
- 関連するオブジェクトをまとめて管理する
- 外部からの不正なアクセスを防ぐ
実装例(集約ルートの定義)
export class Course {
private static createGoalsFromDto(goalDtos: CourseGoalDto[]): Result<CourseGoal[], ValidationError> {
const goalResults = goalDtos.map((goalDto) => CourseGoal.createFromDto(goalDto));
return Result.combineWithAllErrors(goalResults).mapErr((errors) => ValidationError.combine(errors));
}
public getGoals(): CourseGoal[] {
return [...this.goals]; // 防御的コピーを返す
}
}
3. CQRS:読み取りと書き込みの分離
CQRS(Command Query Responsibility Segregation) とは、データの 書き込み(コマンド) と 読み取り(クエリ) を分離する設計パターンです。
CQRSのメリット
- 読み取りを最適化できる
- システムのスケーラビリティを向上できる
- 読み取りと書き込みの責任を明確に分離できる
3.1 コマンド(書き込み)
コマンド側では、ドメインモデルを使用し、ビジネスルールを適用します。以下のuseCase.execute
メソッド内ではエンティティのオブジェクトを作成したのちに、そのオブジェクトを利用してデータベースに値が保存されます。こうすることで要件に沿わない不正な値が永続化されることを防ぎます。
実装例(コマンド側の処理)
export async function createCourse(payload: CreateCourseFormPayload): Promise<CreateCourseFormState> {
const user = await authorize("...");
const result = validationSchema.safeParse(payload);
if (!result.success) {
// エラーハンドリング
}
const useCase = createCreateCourseUseCase(courseRepository, ...);
return useCase.execute({ auth: { user }, args: result.data });
}
3.2 クエリ(読み取り)
クエリ側では、Prismaの柔軟性を最大限に発揮しUIのニーズに最適化されたデータを提供しやすくするために、前述のエンティティや値オブジェクトを使用せずに直接Prismaのクエリを実行します。
実装例(クエリ側の処理)
export class PrismaCourseQueryService implements CourseQueryService {
findCourses(userId: string, pagination: Params): ResultAsync<Course[], Error> {
return ResultAsync.fromPromise(
prisma.Course.findMany(...),
(e) => // エラーハンドリング
);
}
}
4. まとめ
本記事では、Next.jsとPrismaを活用し、DDDの一部の概念を取り入れた型安全でセキュアな実装方法について解説しました。
- エンティティと値オブジェクト:型安全性を高め、データの整合性を維持
- 集約とルート:関連するオブジェクトをまとめ、データの一貫性を確保
- CQRS:読み取りと書き込みを分離し、最適なデータ管理を実現
これらの概念を活用することで、堅牢で拡張性の高いアプリケーションを実装できます。DDD全体を導入するのではなく、必要な要素を適切に取り入れることで、開発効率と保守性を向上させることが可能です。ぜひ、皆さんのプロジェクトにも取り入れてみてください!
TECH PLAY Academyの既存社員向け研修の詳細はこちらから