はじめに
はじめまして!今年の5月入社で Product Unit SINIS for X 開発チームに所属となりました西野です。
今回は自身が初めて業務で触れるにあたって学んだドメイン駆動設計(domain-driven design 以下、DDD)について、基礎的な内容をお話ししながら実際にコードを書いていきます。
なお、本記事の執筆にあたっては書籍ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本を大いに参考とさせていただきました。
イントロダクション
対象読者
- オブジェクト指向プログラミングの基礎知識がある方
- 言語はTypeScriptを用いていますが、他のオブジェクト指向言語に関する知識があれば内容は読み解けるかと思います。
- DDD初学者〜初級者
DDDとは?
DDDはエリック・エヴァンス氏が提唱し、同氏の著書『ドメイン駆動設計』によって広められたソフトウェア開発における設計手法の一つです。
この手法はソフトウェアが対象とするドメイン(領域)の知識をコードで表現することに重きを置いています。
なぜDDDなのか?
DDDと聞いて、難しいという印象を持つ方は多いと思います。筆者自身、その内の一人です。
単純な実装であれば、DDDの考え方を適用したオブジェクト指向的な設計*1よりも手続き的に書かれたコードの方が直感的に理解しやすいことがあります。
実際に、今回の記事で題材とするようなコードであればDDDのメリットはあまり感じられないのではないかと思います。
しかし、実際の業務というのは当然もっと複雑であることに加えて、ソフトウェアはビジネス要件や技術要件によって日々変化していくことを求められます。
また、ある程度の規模のシステムでは、一人の開発者だけで開発を担うことは稀で、複数人のチームで共同開発を行うことが一般的です。プロダクトが成長するにつれてチームも成長し、新たな開発者が加わることも多いです。
そんな中で、ドメインの業務知識をコードに落とし込み、変化に強いソフトウェアを作るための指針となるのがDDDです。
当たり前ですが、DDDは設計手法の一つでしかなく、全てのケースに適するものではありません。ですが、長く存続して成長し続けていくソフトウェアを作りたいのであれば採用の価値は十二分にあるはずだと感じています。
話さないこと
- 利用している言語・技術の仕様
- アーキテクチャ
作成するもの
この記事では、RPGゲームアプリケーションのプレイヤーデータを管理するためのCRUD APIを作成します。
- POST
/functions/v1/players
- プレイヤーデータの新規登録
- GET
/functions/v1/players/:id
- 特定のIDを持つプレイヤーデータの取得
- PUT
/functions/v1/players/:id
- 特定のIDを持つプレイヤーデータの更新
- DELETE
/functions/v1/players/:id
- 特定のIDを持つプレイヤーデータの削除
プレイヤー情報を保持する players
テーブルは以下のデータを持ちます。
Name | Format | Description |
---|---|---|
id | uuid | |
hit_point | smallint | ステータスの一つ。ヒットポイント(体力)を表す |
attack_point | smallint | ステータスの一つ。攻撃力を表す |
defense_point | smallint | ステータスの一つ。防御力を表す |
created_at | timestamp with time zone | |
updated_at | timestamp with time zone |
ディレクトリ構造は以下のようになっています。
supabase/functions ├── healthcheck │ └── index.ts ├── players │ └── index.ts └── src ├── application │ ├── dto │ │ └── PlayerData.ts │ └── services │ ├── PlayerCreateService.ts │ ├── PlayerDeleteService.ts │ ├── PlayerFindService.ts │ └── PlayerUpdateService.ts ├── domain │ ├── entities │ │ └── Player.ts │ └── value-objects │ ├── AttackPoint.ts │ ├── DefensePoint.ts │ ├── HitPoint.ts │ └── UniqueIdentifier.ts ├── infrastructure │ └── persistence │ ├── repositories │ │ └── playerRepository │ │ ├── IPlayerRepository.ts │ │ └── PlayerRepository.ts │ └── supabase │ ├── database.types.ts │ └── supabaseClient.ts └── presentation ├── controllers │ └── PlayerController.ts └── requests ├── PostPlayerRequest.ts └── PutPlayerRequest.ts
全体のコードは以下から参照できます。
APIエンドポイントにリクエストを送信し、期待通りのレスポンスが返ってくることを確認するところまでをこの記事のゴールとします。
利用技術
- TypeScript v5.5.2
- Deno v1.45.2
- Oak v16.1.0
- Supabase v1.183.5 (PostgreSQL)
- Edge Functions を利用
なお、選定理由は単なる筆者の趣味です。
DDDの基本概念
ドメインモデル・ドメインオブジェクト
ソフトウェア開発においては、まずは対象となるドメインにおける重要な概念や業務上の重要事項を正しく理解することが不可欠です。
ドメインの理解を抽象化し、ソフトウェアにとって必要な情報を表現した概念がドメインモデルとなります。
例えば対象となるドメインがアパレル業界であれば、ユーザーの身長やスリーサイズといった身体情報は重要な要素になり得ますが、ゲームであればユーザー(プレイヤー)にとってそれらはまったく重要な情報ではありません。
同じ「自動車」というモノであっても、製造の文脈であれば必要な部品が重要であり、販売の文脈であれば値段が、利用の文脈であれば操作方法が重要な情報たり得ます。それぞれの場合でソフトウェアにおいてどのように表現するかは当然変わってくるはずです。
これらの知識をコードで具体的に表現したものがドメインオブジェクトとなります。
次に示す値オブジェクトやエンティティがこのドメインオブジェクトです。
値オブジェクト
プログラミング言語には文字列型や数値型などのプリミティブ値が存在します。
例えばヒットポイントを表現する時、ただの数値型にしてしまうと負の値を許容してしまいます。一般的なゲームでは、プレイヤーのヒットポイントが負の値になることはなく、0になった時点で戦闘不能と見なすべきです。
また、ヒットポイントを表現する値のはずなのに攻撃力の値が入ってしまうというような間違いも仕組みで防げるようにしたいものです。
ふるまいや制約を値自身に持たせることによって、表現力を与えたものが値オブジェクトとなります。
そうすることによって、不正な値の存在や誤った代入を防ぎ、さらにその値オブジェクトのふるまいに関するロジックを集約することができます。
コード例
まずはプレイヤーの識別子として利用するIDを値オブジェクトとして以下のように表現します。
// supabase/functions/src/domain/value-objects/UniqueIdentifier.ts export class UniqueIdentifier { constructor(public readonly value: string) { if (!UniqueIdentifier.isValid(value)) { throw new Error("Invalid UniqueIdentifier"); } } public static generate = (): UniqueIdentifier => { return new UniqueIdentifier(crypto.randomUUID()); }; // UUIDの形式を表す正規表現に合致するかどうかを検証する public static isValid = (value: string): boolean => { const uuidRegex: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(value); }; }
識別子として用いるUUIDの形式を値オブジェクトの isValid
メソッドで表現しています。
インスタンス生成時にチェックを行い、形式に当てはまらない場合は例外を送出することで予期しない値の存在を防ぎます。
また、再構築を行うコンストラクタとは別に generate
メソッドを用意し、新たにUUIDを生成する際はこちらを利用するようにします。 crypto.randomUUID()
はJavaScript標準のモジュールを利用したUUID v4の生成です。
なお、UUIDのような他の識別子との関連性を持たない識別子ではなく、リレーショナルデータベースのオートインクリメントIDなど前後関係を持つような識別子を利用している場合は生成時にデータベースを参照する必要があります。詳しくは後述のリポジトリの章で説明しますが、値オブジェクトは特定の技術基盤に依存するべきではありません。そのため、そのような場合には生成専用のクラスとしてファクトリを用意するといった工夫が必要になります*2。
続いて、プレイヤーのステータスの一つであるヒットポイントを値オブジェクトとして表現します。
// supabase/functions/src/domain/value-objects/HitPoint.ts export class HitPoint { public readonly value: number; private readonly _min = 0; private readonly _max = 999; constructor(value: number) { this.value = Math.min(Math.max(value, this._min), this._max); } public reduce = (damage: number): HitPoint => { return new HitPoint(this.value - damage); }; }
この値オブジェクトでは、最小値と最大値をそれぞれ設定し、初期化時に範囲外の値が渡された場合には、指定された範囲内の最も近い値に丸めています。
こうすることにより、負もしくは上限を超える値を持つインスタンスの存在を防ぐことができます。
ここで範囲外の値が渡されること自体が異常であるとして例外を送出するのも選択肢の一つだと思います。
ゲームの処理において、ヒットポイントを上回るダメージを受けた場合にダメージ計算後のヒットポイントを0とするのは自然なこと(反対に回復したときも同様)であるため、ここではこのようにしています。
value
は readonly
とすることで、インスタンス生成後の値の書き換えを防ぎ、不変性を担保します。
また、この例では reduce
メソッドの引数 damage
をプリミティブな数値型としていますが、実際にはこれもダメージ値を表す値オブジェクトにすることによってさらに堅牢なコードにすることができるでしょう。
ただし、プリミティブ値を全て排除して何でも値オブジェクトとするべきかどうかは一考の余地があります。
その値が持つべき制約やふるまい、ドメインの中での立ち位置を考慮して値オブジェクトとして定義するべきかどうかを判断するべきです。
エンティティ
エンティティも値オブジェクトと同様にドメインモデルを実装したドメインオブジェクトに分類されますが、エンティティは属性ではなく同一性によって区別される点が異なります。
例えば、値オブジェクトとして表現していたヒットポイントは同じ数値であれば区別する必要はありません。
一方で、全てのステータスが同じプレイヤーが2人いた場合、それらは別のプレイヤーとして区別したいです。
この区別を実現するために、エンティティには一意の識別子が必要となります。
また、値オブジェクトがそれ自体は不変で、交換によって更新を実現していたのに対してエンティティは可変の性質を持ちます。
実際に、プレイヤーは戦闘を行う中でステータスが常に変わり続けるはずです。
コード例
戦闘を行う主体となるプレイヤーをエンティティとして表現します。
// supabase/functions/src/domain/entities/Player.ts import { AttackPoint } from "../value-objects/AttackPoint.ts"; import { DefensePoint } from "../value-objects/DefensePoint.ts"; import { HitPoint } from "../value-objects/HitPoint.ts"; import { UniqueIdentifier } from "../value-objects/UniqueIdentifier.ts"; export class Player { constructor( public readonly id: UniqueIdentifier, public readonly hitPoint: HitPoint, public readonly attackPoint: AttackPoint, public readonly defensePoint: DefensePoint, ) { } public static create = ( hitPoint: HitPoint, attackPoint: AttackPoint, defensePoint: DefensePoint, ): Player => { return new Player( UniqueIdentifier.generate(), hitPoint, attackPoint, defensePoint, ); }; public update = ( hitPoint: HitPoint | null = null, attackPoint: AttackPoint | null = null, defensePoint: DefensePoint | null = null, ): Player => { return new Player( this.id, hitPoint ?? this.hitPoint, attackPoint ?? this.attackPoint, defensePoint ?? this.defensePoint, ); }; }
なお、 AttackPoint
, DefensePoint
もそれぞれ値オブジェクトとして定義しています。
値オブジェクトの例で定義した UniqueIdentifier
を識別子として利用することで、同一性を担保します。
このエンティティも UniqueIdentifier
と同様にコンストラクタは再構築時に利用し、生成には create
メソッドを利用します。
各フィールドは readonly
とすることで意図しない値の書き換えを防ぎ、更新には update
メソッドを利用します。
update
メソッドでは id
が同一のインスタンスを再生成してそれを返します。もとのインスタンスは捨てられるので厳密には違うものになりますが、 id
が同一であることによって事実上同じオブジェクトであるとして扱うことができます。
リポジトリ
値オブジェクトもエンティティも実態はクラスであり、そのインスタンスはメモリ上に保持される揮発性のデータです。
ソフトウェアとして成り立たせるためには、データを永続化させるためのデータストアが必要不可欠です。
リポジトリはデータストアへの永続化や再構築といった処理を抽象化する役割を担います。
ドメインオブジェクトにとって、データストアにリレーショナルデータベースを用いるか、NoSQLデータベースを用いるかといった話は重要ではありません。具体的な処理をドメインオブジェクトに行わせると、本来重要な業務ロジックにノイズが入り込んでしまいます。
また、ソフトウェアの成長に伴い発生した技術的な課題によって、将来的にデータストアを変える必要に迫られる可能性も考えられます。
そういった時に、データストアとのやり取りを行うコードがリポジトリとしてきちんと独立していれば、用意するべきコードは新しいリポジトリのものだけで済みます。
コード例
プレイヤーのリポジトリを作成するにあたり、まずインターフェースを定義します。
// supabase/functions/src/infrastructure/persistence/repositories/playerRepository/IPlayerRepository.ts import { Player } from "../../../../domain/entities/Player.ts"; import { UniqueIdentifier } from "../../../../domain/value-objects/UniqueIdentifier.ts"; export interface IPlayerRepository { find(id: UniqueIdentifier): Promise<Player | null>; store(player: Player): Promise<void>; delete(id: UniqueIdentifier): Promise<void>; }
id
を指定した検索、エンティティの永続化、 id
を指定した削除のメソッドを用意します。
続いてこれを実装します。
// supabase/functions/src/infrastructure/persistence/repositories/playerRepository/PlayerRepository.ts import { Player } from "../../../../domain/entities/Player.ts"; import { AttackPoint } from "../../../../domain/value-objects/AttackPoint.ts"; import { DefensePoint } from "../../../../domain/value-objects/DefensePoint.ts"; import { HitPoint } from "../../../../domain/value-objects/HitPoint.ts"; import { UniqueIdentifier } from "../../../../domain/value-objects/UniqueIdentifier.ts"; import { supabase } from "../../supabase/supabaseClient.ts"; import { IPlayerRepository } from "./IPlayerRepository.ts"; export class PlayerRepository implements IPlayerRepository { public find = async (id: UniqueIdentifier): Promise<Player | null> => { const { data } = await supabase .from("players") .select("*") .eq("id", id.value) .single(); if (!data) { return null; } return new Player( new UniqueIdentifier(data.id), new HitPoint(data.hit_point), new AttackPoint(data.attack_point), new DefensePoint(data.defense_point), ); }; public store = async (player: Player): Promise<void> => { const { error } = await supabase.from("players").upsert({ id: player.id.value, hit_point: player.hitPoint.value, attack_point: player.attackPoint.value, defense_point: player.defensePoint.value, }); if (error) { throw error; } }; public delete = async (id: UniqueIdentifier): Promise<void> => { const { error } = await supabase .from("players") .delete() .eq("id", id.value); if (error) { throw error; } }; }
ここでは処理の詳細は解説しませんが、今回はSupabaseを利用しているので、SupabaseのAPIを利用してデータベースとのやり取りを行なっています。
このように、特定の技術基盤に依存する処理はリポジトリに閉じ込めるようにします。
別のデータストアを利用したくなった場合は、 IPlayerRepository
の実装を差し替えるだけで済みます。
次章のアプリケーションサービスからの依存もインターフェースに向いていれば、リポジトリ外に影響が波及することもありません(いわゆる依存関係逆転の原則ですね)。
アプリケーションサービス
ここまでで定義したドメインオブジェクトやリポジトリを利用して、ユースケースを実現するのがアプリケーションサービスの役割です。
APIの代表的なユースケースであるCRUD操作はまさにアプリケーションサービスで実現されるべきものです。
コード例
プレイヤー新規登録のユースケースをアプリケーションサービスとして実装します。
// supabase/functions/src/application/services/PlayerCreateService.ts import { Player } from "../../domain/entities/Player.ts"; import { AttackPoint } from "../../domain/value-objects/AttackPoint.ts"; import { DefensePoint } from "../../domain/value-objects/DefensePoint.ts"; import { HitPoint } from "../../domain/value-objects/HitPoint.ts"; import { IPlayerRepository } from "../../infrastructure/persistence/repositories/playerRepository/IPlayerRepository.ts"; import { PlayerData } from "../dto/PlayerData.ts"; export class PlayerCreateService { constructor(private readonly _playerRepository: IPlayerRepository) { } public execute = async ( hitPoint: HitPoint, attackPoint: AttackPoint, defensePoint: DefensePoint, ): Promise<PlayerData> => { const player = Player.create( hitPoint, attackPoint, defensePoint, ); // リポジトリに永続化を依頼 await this._playerRepository.store(player); // エンティティをDTOに詰め替えて返す return new PlayerData(player); }; }
必要な値オブジェクトを受け取り、作成したエンティティを用いてリポジトリに永続化を依頼します。
新規登録のPOSTのレスポンスに登録したデータを含めるかどうかはAPIの仕様次第ですが、ここでは返すようにしています。
その際にエンティティをそのまま返すのではなく、DTO(Data Transfer Object)を用意してそちらにデータを詰め替えして返却しています。
DTOは、データを転送するためのオブジェクトです。ここでは、エンティティのデータを外部に公開する際に利用します。
DTOの実装は以下になります。
// supabase/functions/src/application/dto/PlayerData.ts import { Player } from "../../domain/entities/Player.ts"; export class PlayerData { public readonly id: string; public readonly hitPoint: number; public readonly attackPoint: number; public readonly defensePoint: number; constructor(player: Player) { this.id = player.id.value; this.hitPoint = player.hitPoint.value; this.attackPoint = player.attackPoint.value; this.defensePoint = player.defensePoint.value; } }
なぜこのようにするのかというと、アプリケーションサービス外でドメインオブジェクトのふるまいのロジックが呼び出されることを防ぐためです。
アプリケーションサービス利用側で必要なのは各フィールドの値だけであり、 update
による更新などは必要ないしさせるべきでないということです。
プレゼンテーション層以降の実装
ここからはフレームワークの仕様に依存する部分の説明は省略し、要点のみを説明します。
今回はDenoのフレームワークであるOakを利用しています。
コントローラ
コントローラはリクエストを受け取り、バリデーションを行った後、アプリケーションサービスを呼び出します。
// supabase/functions/src/presentation/controllers/PlayerController.ts import { httpErrors, RouterContext, Status } from "@oak/oak"; import { PlayerCreateService } from "../../application/services/PlayerCreateService.ts"; import { PostPlayerRequest } from "../requests/PostPlayerRequest.ts"; import { HitPoint } from "../../domain/value-objects/HitPoint.ts"; import { AttackPoint } from "../../domain/value-objects/AttackPoint.ts"; import { DefensePoint } from "../../domain/value-objects/DefensePoint.ts"; import { PlayerFindService } from "../../application/services/PlayerFindService.ts"; import { UniqueIdentifier } from "../../domain/value-objects/UniqueIdentifier.ts"; import { PlayerUpdateService } from "../../application/services/PlayerUpdateService.ts"; import { PutPlayerRequest } from "../requests/PutPlayerRequest.ts"; import { PlayerDeleteService } from "../../application/services/PlayerDeleteService.ts"; export class PlayerController { constructor( private readonly _playerCreateService: PlayerCreateService, private readonly _playerFindService: PlayerFindService, private readonly _playerUpdateService: PlayerUpdateService, private readonly _playerDeleteService: PlayerDeleteService, ) {} public postPlayer = async ( context: RouterContext<string>, ) => { const request = await context.request.body.json(); // リクエストパラメータのバリデーションを行う(不適切だった場合は httpErrors.BadRequest エラーを送出する) const validated = PostPlayerRequest.validate(request); const { hitPoint, attackPoint, defensePoint } = validated; const data = await this._playerCreateService.execute( new HitPoint(hitPoint), new AttackPoint(attackPoint), new DefensePoint(defensePoint), ); // 成功した場合はステータスコード201とし、作成したPlayerをレスポンスに含めて返す context.response.status = Status.Created; context.response.body = data; }; // 以下省略
ルーティング
Supabase Edge Functionsの関数内で、Oakによるルーティングを行います。
// supabase/functions/players/index.ts import { Application, httpErrors, Router, Status } from "@oak/oak"; import { PlayerCreateService } from "../src/application/services/PlayerCreateService.ts"; import { PlayerRepository } from "../src/infrastructure/persistence/repositories/playerRepository/PlayerRepository.ts"; import { PlayerController } from "../src/presentation/controllers/PlayerController.ts"; import { PlayerFindService } from "../src/application/services/PlayerFindService.ts"; import { PlayerUpdateService } from "../src/application/services/PlayerUpdateService.ts"; import { PlayerDeleteService } from "../src/application/services/PlayerDeleteService.ts"; const app = new Application(); const router = new Router(); // 依存関係の解決 const playerCreateService = new PlayerCreateService(new PlayerRepository()); const playerFindService = new PlayerFindService(new PlayerRepository()); const playerUpdateService = new PlayerUpdateService(new PlayerRepository()); const playerDeleteService = new PlayerDeleteService(new PlayerRepository()); const playerController = new PlayerController( playerCreateService, playerFindService, playerUpdateService, playerDeleteService, ); router .post("/players", playerController.postPlayer) .get("/players/:id", playerController.getPlayer) .put("/players/:id", playerController.putPlayer) .delete("/players/:id", playerController.deletePlayer); // 全てのエンドポイントに対して共通で適用するエラーハンドリング app.use(async (context, next) => { try { await next(); } catch (error) { if (error instanceof httpErrors.NotFound) { context.response.status = Status.NotFound; context.response.body = { message: error.message }; return; } if (error instanceof httpErrors.BadRequest) { context.response.status = Status.BadRequest; context.response.body = { message: error.message }; return; } context.response.status = Status.InternalServerError; context.response.body = { message: "Internal server error" }; console.error(error); } }); app.use(router.routes()); app.use(router.allowedMethods()); await app.listen({ port: 8000 });
ここで、コントローラを呼び出すために各アプリケーションサービスとコントローラの依存関係を解決している部分については改善の余地があります。
アプリケーションサービスやコントローラの依存関係が追加されるたびに、ルーティングに影響が及ぶのは適切ではありません。
フレームワークによってはDI(Dependency Injection)コンテナがよしなにやってくれる場合もありますが、今回のようにそうではない場合も依存関係解決のためにDIコンテナを利用するための仕組みを取り入れるのが好ましいでしょう。
また、共通で適用するエラーハンドリングについてはミドルウェアとして切り出すといった工夫もできそうです。
動作確認
ローカルサーバーを立ち上げ、以下のコマンドでプレイヤー新規登録のAPIを実行して動作確認を行います。
curl --location 'http://127.0.0.1:54321/functions/v1/players' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer {{SUPABASE_ANON_KEY}}' \ --data '{ "hitPoint": 100, "attackPoint": 30, "defensePoint": 20 }'
結果、 201 Created
でレスポンスが返り、データストアに正常にデータが登録されることが確認できます。
リクエストパラメータに範囲外の値( "hitPoint": 9999
など)を指定した場合は値が丸められて登録され、文字列を指定したり、パラメータを省いたりした場合は 400 Bad Request
レスポンスが返り、データストアには登録されないことが確認できます。
まとめ
この記事では簡単なAPIの実装を通じて、DDDの基礎的な概念について説明しました。
他にもドメインサービス、境界づけられたコンテキスト、CQRSなど、DDDにはまだまだ理解すべき多くの概念があります。しかし、こういった様々な概念が存在することがDDDを難しいと印象付ける一因でもあると感じています。
まずは比較的平易な概念から理解していくことで、そういった印象も緩和されるのではないでしょうか。
今後も、DDDについて自分のペースでしっかりと理解し、実践していきたいと思います!
テテマーチでは、一緒にサービスを作り上げてくれる仲間を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください! herp.careers
エンジニアチームガイドはこちら! tetemarche01.notion.site
*1:DDDを実践するにあたり、必ずしもオブジェクト指向である必要はありませんが、ここではオブジェクト指向言語によるDDDを扱います。
*2:筆者としては、そのような場合はデータストアの識別子と後述のエンティティの識別子とは切り離して考えるのが好ましいと考えています。