DDD の戦術的設計パターンを実践します。
以下のセクションに分かれております。
- ドメインレイヤー
- アプリケーションレイヤー
- プレゼンテーション / インフラストラクチャー レイヤー (本記事)
記事内のソースコードの記述等をなるべくコンパクトにするために、完全にボトムアップで実装していきます。
リポジトリ
その他、採用アーキテクチャーやテーマについては Part 1 冒頭をご参照ください。
プレゼンテーション / インフラストラクチャー レイヤー
これらのレイヤーの性質上、特定のフレームワークに依存した実装が主に取り上げられますが、 DDD において特定のフレームワークの機能や使い方などは、重要ではありません。
ざっくりかいつまんで確認していく程度にします(所謂非機能要件)。
プレゼンテーションレイヤー (ユーザーインターフェースレイヤー)
HTTPのRESTライクなインターフェースを公開することになりました。
例外フィルター
NestJS には例外を受け取り、ユーザーに最適な応答をするための Exception filters があります。
例外フィルターはプレゼンテーション層の責務に該当するでしょう。
アプリケーションサービスから受け取った例外を個別に扱うケースもあるかもしれませんが、こちらを使用していくつか共通の振る舞いを定義していきます。
@Catch(ValidationDomainException)
export class ValidationDomainExceptionFilter implements ExceptionFilter {
catch(exception: ValidationDomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.BAD_REQUEST;
response
.status(statusCode)
.json({ statusCode, message: exception.message });
}
}
@Catch(UnexpectedDomainException)
export class UnexpectedDomainExceptionFilter implements ExceptionFilter {
catch(exception: UnexpectedDomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
Logger.error(exception.message, exception.stack, exception.cause);
response
.status(statusCode)
.json({ statusCode, message: 'An unexpected error ocurred.' });
}
}
これらの例外フィルターではまず、例外をHttpStatusにマッピングしています。
ValidationDomainException
をそのまま受け取った場合は、HTTPの世界での BAD_REQUEST(400)
にマッピングしていいと判断した、ということになります。
また、エラーメッセージもそのままの形で公開して良いと判断され、レスポンスに詰め込んでいます。
特定のエンドポイントや例外に応じて異なるステータスコードを割り当てたい場合や、エラーメッセージをそのままの形で見せたく無い場合などもあるかと思います
(エンドユーザーに対して再入力を促すようなメッセージを追加したい 、など)。
基本的にドメイン層例外はドメイン層の表現上必要なエラーメッセージを用意しているだけで、特定のエンドユーザーのことなどは意識しておりません。
そういった場合は、それぞれのエンドポイントの実装でインラインで例外キャッチしたり、個別のフィルターを用意してレスポンスを作ることになります。
想定外の例外を扱う UnexpectedDomainExceptionFilter
では、エラーログを出力するようにしており、またエンドユーザーに対してはその詳細を隠蔽します。
想定外といっても UnexpectedDomainException
はこちらで意図的に使用する例外なので、攻撃の糸口となるような内部情報を詰め込むことはなさそうですが、念の為。
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
Logger.error(exception);
response
.status(statusCode)
.json({ statusCode, message: 'An unexpected error ocurred.' });
}
}
こちらは、その他の補足できなかった全ての例外に対するフィルターです
(むしろこっちが、本当の意味での想定外例外へのフィルターになります)。
UnexpectedDomainException
と同様に、 INTERNAL_SERVER_ERROR(500)
を割り当て、エラーログを出力しています。
クッキーを利用したセッションの保持
export class UserSessionCookie {
private static readonly COOKIE_NAME = 'ddd-onion-lit_usid';
private static readonly COOKIE_MAX_AGE = 1000 * 60 * 60 * 24 * 30;
constructor(private readonly configService: ConfigService) {}
get(request: Request): SessionId | undefined {
return request.cookies[UserSessionCookie.COOKIE_NAME];
}
set(response: Response, sessionId: SessionId) {
response.cookie(UserSessionCookie.COOKIE_NAME, sessionId, {
httpOnly: true,
secure: this.configService.get('NODE_ENV') === 'production',
maxAge: UserSessionCookie.COOKIE_MAX_AGE,
});
}
}
HTTPクライアントとのセッションのやり取りにクッキーを使用することになりました。
クッキーに対する get
と set
ができれば大丈夫でしょう。
認可ガード
export class AuthGuard implements CanActivate {
constructor(
private readonly userSessionCookie: UserSessionCookie,
private readonly availableUserSessionProvider: AvailableUserSessionProvider,
) {}
async canActivate(context: ExecutionContext) {
const httpContext = context.switchToHttp();
const req = httpContext.getRequest<Request>();
const sessionId = this.userSessionCookie.get(req);
if (!sessionId) throw new UnauthorizedException('Authentication required.');
const userSession =
await this.availableUserSessionProvider.handle(sessionId);
if (!userSession)
throw new UnauthorizedException('Authentication required.');
req.userSession = userSession;
return true;
}
}
NestJS ではルートを保護する Guards が提供されています。
DDD の文脈的に厳密には、個々のルートよりもアプリケーションサービスを保護する、という表現の方が正しいかもしれませんが、やはりこちらは便利です。
- cookieからセッションIDを取得
- セッションIDから利用可能なユーザーセッション情報を取得
- ユーザーセッション情報をリクエストのコンテキストに詰める
いずれかのフローが失敗した場合、 UnauthorizedException
がthrowされます。
ログインコントローラー
必要な共有リソースが揃ったのでパス単位でコントローラーを作っていきます。
@UseFilters(...filters)
@Controller('login')
export class LoginController {
constructor(
private readonly loginUseCase: LoginUseCase,
private readonly userSessionCookie: UserSessionCookie,
) {}
@ApiOkResponse()
@Post()
@HttpCode(200)
async login(
@Body()
request: LoginRequest,
@Res()
response: Response,
) {
const { sessionId } = await this.loginUseCase.handle({
emailAddress: request.emailAddress,
});
this.userSessionCookie.set(response, sessionId);
response.send();
}
}
ログインのユースケースが成功したらセッションIDをクッキーに詰めます。
タスクコントローラー
@UseFilters(...filters)
@UseGuards(AuthGuard)
@Controller('tasks')
export class TaskController {
constructor(
private readonly findTasksUseCase: FindTasksUseCase,
private readonly findTaskUseCase: FindTaskUseCase,
private readonly createTaskUseCase: CreateTaskUseCase,
private readonly addCommentUseCase: AddCommentUseCase,
private readonly assignUserUseCase: AssignUserUseCase,
) {}
@ApiOkResponse({ type: [TaskListItem] })
@Get()
async find(): Promise<TaskListItem[]> {
const { tasks } = await this.findTasksUseCase.handle();
return tasks;
}
@ApiOkResponse({ type: TaskDetails })
@Get(':id')
async findOne(@Param('id') id: string): Promise<TaskDetails> {
const { task } = await this.findTaskUseCase.handle({ id });
return {
...task,
comments: task.comments.map((comment) => ({
...comment,
postedAt: new Date(
comment.postedAt.year,
comment.postedAt.month - 1,
comment.postedAt.date,
comment.postedAt.hours,
comment.postedAt.minutes,
).toLocaleString(),
})),
};
}
@ApiCreatedResponse({ type: TaskCreatedId })
@Post()
async create(
@Body()
request: CreateTaskRequest,
): Promise<TaskCreatedId> {
const { id } = await this.createTaskUseCase.handle({
taskName: request.name,
});
return {
id,
};
}
@ApiNoContentResponse()
@Put(':id/comment')
@HttpCode(204)
async addComment(
@Param('id') id: string,
@Body()
request: AddCommentRequest,
@Req()
{ userSession }: Request,
) {
await this.addCommentUseCase.handle({
taskId: id,
userSession: userSession,
comment: request.comment,
});
}
@ApiNoContentResponse()
@Put(':id/assign')
@HttpCode(204)
async assignUser(
@Param('id') id: string,
@Body()
request: AssignUserRequest,
) {
await this.assignUserUseCase.handle({
taskId: id,
userId: request.userId,
});
}
}
/tasks
配下の全てのエンドポイントが AuthGurad
で保護されております。
やっていることは、パスやHTTPメソッドに応じてユースケースを実行し、ユーザーに適切なレスポンスを返しているだけです。
一応ユーザーインターフェース観点でのバリデーションも実装しています。
export class CreateTaskRequest {
@IsString()
@MinLength(1)
@ApiProperty()
readonly name!: string;
}
こちらはタスクを新規作成する際のリクエストボディです。
IsString
は class-validator が提供するデコレーターで、 undefined や null を含む文字列以外の全ての型を弾いてくれます。
タスク名バリューオブジェクトでは、文字列以外の値が渡される可能性は考慮しておりません。
export class TaskName {
constructor(value: string) {
if (value.length > TaskName.TASK_NAME_CHARACTERS_LIMIT) {
throw new TaskNameCharactersExceededException(
TaskName.TASK_NAME_CHARACTERS_LIMIT,
);
}
this._value = value;
}
}
以下のように極端に防御的な記述をしてしまうと、無意味にドメインオブジェクトが複雑になってしまう上に、本来のビジネスルールが分かりづらくなってしまうからです。
constructor(value: string) {
if (value === undefined) {
throw new UnexpectedDomainException();
}
if (value === null) {
throw new UnexpectedDomainException();
}
if (typeof value !== 'string') {
throw new UnexpectedDomainException();
}
if (value.length === 0) {
throw new UnexpectedDomainException();
}
if (value.length > TaskName.MAX_TASK_NAME_LENGTH) {
throw new TaskNameCharactersExceededException(
TaskName.TASK_NAME_CHARACTERS_LIMIT,
);
}
this._value = value;
}
ドメイン層で表現するには相応しくない粗いレベルのバリデーションをプレゼンテーション層で行い、ドメイン層やアプリケーション層を保護しています。
なお、プレゼンテーション層にビジネスルールのバリデーションもさらに追加するという手法については賛否両論あるようですが、 IDDD では推奨されていませんでした。
ユーザーインターフェイスではあくまでも粗いレベルのバリデーションにとどめ、業務に関する深い知識はモデルの中だけで表現するようにしたい。
(実践ドメイン駆動設計)
ユーザー作成 コマンダー
ユーザーを作成する権限や方法についてはまだ決められていませんでしたが、一旦、
- アプリケーションは一部の管理者のみがログインできるプライベートなサーバーで稼働する
- そのことを利用し、管理者がサーバーにログインし Nest Commander を使用したコマンドライン経由でユーザーを作成する
という方針になりました。
@Command({
name: 'CreateUser',
description: 'Create user by name and email address.',
})
export class CreateUserCommander extends CommandRunner {
constructor(private readonly createUserUseCase: CreateUserUseCase) {
super();
}
async run(nameAndEmailAddress: string[]) {
const [name, emailAddress] = nameAndEmailAddress;
const { id } = await this.createUserUseCase.handle({
name,
emailAddress,
});
Logger.log(`User successfully created. id: ${id}`);
}
}
入力ストリームから受け取ったパラメーターをユーザー作成ユースケースに渡しています。
(コマンド例)
yarn start:commander CreateUser Michael test@example.com
アプリケーションサービスは特定のユーザーインターフェースに依存しない作りになっているので、要求に応じた柔軟な対応が可能です。
インフラストラクチャーレイヤー
こちらのレイヤーでドメイン層やアプリケーション層が要求している具象を作っていきます。
IDファクトリー
export class TaskIdUuidV4Factory implements TaskIdFactory {
handle() {
return new TaskId(v4());
}
}
export class UserIdUuidV4Factory implements UserIdFactory {
handle() {
return new UserId(v4());
}
}
export class CommentIdUuidV4Factory implements CommentIdFactory {
handle() {
return new CommentId(v4());
}
}
全て、 uuid の Version 4 を使用するという方針になりました。
気になるのは、 CommentId
で、こちらは境界内部のエンティティの識別子、すなわちローカルな識別子になります。
こちらは uuid を使う必要がなく、例えば 1 ~ 20 の連番とかでも大丈夫です。
エヴァンズの例で分かりやすかったのは、車が持つ車輪の識別子として、 左前・左後・右前・右後 といった位置を用いていました。
ただし、 CommentId
に関しては車輪と違って、現実世界に沿った分かりやすい識別手段も特に思いつかなかったので適当に楽な生成方法として uuidフレームワーク を採用しました。
機能上の問題は特に無さそうです。
ユーザーセッションインメモリーストレージ
export class UserSessionInMemoryStorage implements UserSessionStorage {
private readonly value: Map<SessionId, UserSession> = new Map();
async get(sessionId: SessionId) {
const userSession = this.value.get(sessionId);
return userSession;
}
async set(userSession: UserSession) {
const sessionId = Math.random().toString();
this.value.set(sessionId, userSession);
return sessionId;
}
}
ユーザーセッションストレージの実装クラスになります。
現時点では実際に使用するストレージを決めきれておらず、とりあえずデバックやテスト用のインメモリーストレージを定義した、というシチュエーションになります。
セッションIDもただの乱数で生成しています。
データモデル ・ ORマッパー
データベースは mysql 、ORマッパーは TypeOrm を使用することになりました。
ER図は以下になります。
何となく適当に正規化しただけのテーブル設計になります。
例えば、 task_assignments
は、タスクへのユーザー割り当てを保持するテーブルになります。
DDD ではドメインオブジェクトの構造をなるべくそのまま反映した、非正規化したテーブルを設計するパターンもあるようですが、今回は一般的なテーブル設計にしてみました。
一応、TypeOrmモデルも示しておきます、が、これもただのTypeOrmの使い方に過ぎないので重要ではありません。
タスクリポジトリ
export class TaskTypeormRepository implements TaskRepository {
constructor(
@InjectRepository(TaskTypeormModel)
private readonly taskRepository: Repository<TaskTypeormModel>,
@InjectRepository(TaskAssignmentTypeormModel)
private readonly taskAssignmentRepository: Repository<TaskAssignmentTypeormModel>,
@InjectRepository(TaskCommentTypeormModel)
private readonly taskCommentRepository: Repository<TaskCommentTypeormModel>,
) {}
async insert(task: Task) {
await this.taskRepository.save({
id: task.id.value,
name: task.name.value,
taskAssignment: task.userId && {
taskId: task.id.value,
userId: task.userId.value,
},
taskComments: task.comments.value.map((comment) => ({
id: comment.id.value,
userId: comment.userId.value,
content: comment.content,
postedAt: comment.postedAt,
})),
});
}
async update(task: Task) {
await this.taskRepository.update(task.id.value, { name: task.name.value });
await this.taskAssignmentRepository.delete({ taskId: task.id.value });
task.userId &&
(await this.taskAssignmentRepository.save({
taskId: task.id.value,
userId: task.userId.value,
}));
await this.taskCommentRepository.delete({ taskId: task.id.value });
await this.taskCommentRepository.save(
task.comments.value.map((comment) => ({
id: comment.id.value,
userId: comment.userId.value,
content: comment.content,
postedAt: comment.postedAt,
taskId: task.id.value,
})),
);
}
async find() {
const tasks = await this.taskRepository.find({
relations: {
taskAssignment: true,
taskComments: true,
},
});
return tasks.map((task) =>
Task.reconstitute(
new TaskId(task.id),
new TaskName(task.name),
task.taskComments.map(
(taskComment) =>
new Comment(
new CommentId(taskComment.id),
new UserId(taskComment.userId),
taskComment.content,
taskComment.postedAt,
),
),
task.taskAssignment?.userId && new UserId(task.taskAssignment.userId),
),
);
}
findOneById () ...
}
タスク集約ルートの永続化と再構成をしています。
早期生成したエンティティIDをそのままプライマリーキーに割り当てていますが、
InnoDB の仕組み上、 uuid をそのままプライマリーキーとして使用してしまうと、パフォーマンスに悪影響を及ぼします。
とりわけ膨大なデータ量を扱う可能性のあるケースや実践においては、早期生成したIDは public_id など、別カラムに割り当てて、プライマリーキーは自動インクリメントの連番などを使用するのが望ましいです。
update
で今回は集約の子オブジェクトに該当するデータを全部削除してから追加し直しています。
新しく必要な分だけ追加する、削除が必要なデータだけ削除する、などの手段を取ることもできますが、削除してから追加し直す方がまだシンプルになります。
タスク一覧クエリーサービス
export class FindTasksTypeormQueryService implements FindTasksQueryService {
constructor(private readonly dataSource: DataSource) {}
async handle() {
const tasks = await this.dataSource.query<
{ id: string; name: string; userName?: string }[]
>(
'SELECT tasks.id as id, tasks.name as name, users.name as userName FROM tasks LEFT JOIN task_assignments ON task_assignments.task_id = tasks.id LEFT JOIN users ON users.id = task_assignments.user_id',
);
return { tasks };
}
}
タスク一覧の取得に最適化したクエリーサービスの実装です。
逆にもっと TypeOrm の機能を使っても良かったのですが、今回は DataSource のみを使ってクエリーを直接発行しています。
必要なカラムのみを SELECT しています。
ありがとうございました
次回があれば、トランザクションや整合性周りについてまとめたいと思います。