振る舞いに応じて型を分けることで複雑さに対処する
アドベントカレンダー 1 日目
本記事はLabBase テックカレンダー Advent Calendar 2023 1 日目です。
はじめに
前回の記事ではDomain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# の読書メモをまとめました。
Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# や、@kawasima さんの記事を読んで、振る舞いに応じて型を分けることで複雑さに対処する方法を実践してみたいと思いました。
そこで、TypeScript で簡単なタスク管理アプリの API を作ってみました。
既存のサンプルコードにデータベースとの接続部分に関する実装が含まれていることが少なかったので、感触を得たいというのが主な目的です。
実装したコードはこちらです。
やらなかったこと
今回の目的と異なるので関数型プログラミングのテクニックを駆使してパイプラインを構成するアプローチは採用しませんでした。また、エラーハンドリングについても雑です。テストも書いてません。余力があれば次回以降でトライしてみたいと思います。
イベントストーミング
上記の記事を参考に タスク管理アプリを題材としてドメインモデリングをしてみます。要件は下記の通りです。
- タスクは必ずタスク名、期日を持つ
- タスクは未完了状態で作成し、完了したら戻すことはできない
- タスクは 3 回だけ、1 日ずつ延期することができる
- タスク名は変更することができない
イベントストーミングをほんのさわりだけ試してみました。
イベントストーミングの進め方はこちらを参考にしました。
所感
- 「タスクの期限を過ぎた」など当初考えていなかったイベントが出てきました。タスクの期限を過ぎた時はどうしましょうか?通知を飛ばす必要がありますかね?
- 複数人でやると、隠れていたイベントを網羅的に発見できそうです。
- 本当はもっと色々出てくると思うのですが、今回はスコープを閉じておきたいので深堀りしていません。
ドメインの文書化
コンテキスト: TaskManagement
- ワークフロー: post
- 入力: UnvalidatedTask
- 出力: PostponableUnDoneTask
- ワークフロー: postpone
- 入力:PostponableUnDoneTask
- 出力:PostponableUnDoneTask | UndoneTaskWithDeadline
- ルール:タスクは 3 回だけ、1 日ずつ延期することができる
- ワークフロー: complete
- 入力: UnDoneTask
- 出力: DoneTask
所感
- 「Domain Modeling Made Functional」では入力イベントと入力値を分けていましたが、分けた方がいいのでしょうか?いまいちピンときてません。
- データに関しては型で表現しても非エンジニアに伝わると思ったのでそうしてます。
- クラスで書いた場合と比較して、要件が守られやすいかどうかを想像してみたのですが、あまり変わらない気がします。
実装
アーキテクチャ
オニオンアーキテクチャを採用して実装しています。IoC コンテナを使用しないので依存の向きは逆転していません。ただし、Domain 層は他の層に依存していません。
所感
- 「Domain Modeling Made Functional」ではオニオンアーキテクチャが提示されていました。サンプルコードは下記のようになっており、レイヤーごとにディレクトリが分かれる形にはなっていません。
- 今回はレイヤーごとにディレクトリを分けてみました。レイヤー間の依存関係が明確になり、依存関係を管理しやすくなると思いましたがどうでしょうか?
ドメイン層
import { z } from "zod";
/**
* ワークフロー
*/
export type Command = {
id: string;
isDone: boolean;
name: string;
dueDate: Date;
postPoneCount: number;
};
export type Post = (command: Command) => PostPonableUnDoneTask;
export const post: Post = (command: Command) => {
return PostPonableUnDoneTask.parse({
kind: POSTPONABLE_UNDONE_TASK,
id: command.id,
name: command.name,
dueDate: command.dueDate,
postPoneCount: command.postPoneCount,
});
};
export type Postpone = (task: PostPonableUnDoneTask) => UnDoneTask;
export const postpone: Postpone = (task: PostPonableUnDoneTask) => {
const newDueDate = new Date(task.dueDate);
newDueDate.setDate(newDueDate.getDate() + 1);
if (task.postPoneCount === 2) {
return UnDoneTaskWithDeadline.parse({
kind: "UnDoneTaskWithDeadline",
id: TaskId.parse(task.id),
name: task.name,
dueDate: newDueDate,
postPoneCount: task.postPoneCount + 1,
});
} else {
return PostPonableUnDoneTask.parse({
kind: "PostPonableUnDoneTask",
id: TaskId.parse(task.id),
name: task.name,
dueDate: newDueDate,
postPoneCount: task.postPoneCount + 1,
});
}
};
export type Complete = (task: UnDoneTask) => DoneTask;
export const complete: Complete = (task: UnDoneTask): DoneTask => {
return DoneTask.parse({
kind: "DoneTask",
id: TaskId.parse(task.id),
name: task.name,
dueDate: task.dueDate,
});
};
/**
* リポジトリ
*/
export type PostTaskRepository = (
postPonableUnDoneTask: PostPonableUnDoneTask
) => Promise<PostPonableUnDoneTask>;
export type CompleteTaskRepository = (doneTask: DoneTask) => Promise<DoneTask>;
export type PostPoneTaskRepository = (
UnDoneTask: UnDoneTask
) => Promise<UnDoneTask>;
/**
* ドメインオブジェクト
*/
export const TaskId = z.string().uuid().brand("TaskId");
export type TaskId = z.infer<typeof TaskId>;
export const TaskName = z.string().min(1).max(50);
export type TaskName = z.infer<typeof TaskName>;
export const POSTPONABLE_UNDONE_TASK = "PostPonableUnDoneTask";
export const PostPonableUnDoneTask = z.object({
kind: z.literal(POSTPONABLE_UNDONE_TASK),
id: TaskId,
name: z.string(),
dueDate: z.date(),
postPoneCount: z.number().min(0).max(2),
});
export type PostPonableUnDoneTask = z.infer<typeof PostPonableUnDoneTask>;
export const UNDONE_TASK_WITH_DEADLINE = "UnDoneTaskWithDeadline";
export const UnDoneTaskWithDeadline = z.object({
kind: z.literal(UNDONE_TASK_WITH_DEADLINE),
id: TaskId,
name: z.string(),
dueDate: z.date(),
});
export type UnDoneTaskWithDeadline = z.infer<typeof UnDoneTaskWithDeadline>;
export const DONE_TASK = "DoneTask";
export const DoneTask = z.object({
kind: z.literal(DONE_TASK),
id: TaskId,
name: z.string(),
dueDate: z.date(),
});
export type DoneTask = z.infer<typeof DoneTask>;
export type Task = UnDoneTask | DoneTask;
export const TASK =
POSTPONABLE_UNDONE_TASK || UNDONE_TASK_WITH_DEADLINE || DONE_TASK;
export type UnDoneTask = PostPonableUnDoneTask | UnDoneTaskWithDeadline;
export const UNDONE_TASK = POSTPONABLE_UNDONE_TASK || UNDONE_TASK_WITH_DEADLINE;
所感
- 今回は zod を使用してドメイン知識を表現してみました。シンプルなコードで高い表現力を持ちますが、ドメイン層が流行り廃りの激しいスキーマバリデーションライブラリに依存する場合は、後日の改修コストを受け入れる必要があるので賛否両論ありそうですね。
- ワークフローから出力されたイベントは過去形になると「Domain Modeling Made Functional」に記載されていたのですが、ドメイン知識をうまく表現できなかったので今回は過去形にしていません。
- 例えば、postpone ワークフローの出力イベントは postponedTask と表現するべきかもしれませんが、postponedTask という名称からは延期回数が 2 回以下 or 3 回のどちらか伝わりません。ただ、次のワークフローに渡す場合に下記のように型を変換すれば良いのかもしれません。
// postponedTaskの延期回数を判定する
if (postponedTask.postPoneCount <= 2) {
return PostPonableUnDoneTask.parse({
kind: "PostPonableUnDoneTask",
id: TaskId.parse(postponedTask.id),
name: postponedTask.name,
dueDate: postponedTask.dueDate,
postPoneCount: postponedTask.postPoneCount,
});
} else {
return UnDoneTaskWithDeadline.parse({
kind: "UnDoneTaskWithDeadline",
id: TaskId.parse(postponedTask.id),
name: postponedTask.name
dueDate: postponedTask.dueDate
});
}
- UnDoneTaskWithDeadline という延期回数が 3 回になって延期できなくなったタスクを表現する際に、プロパティとして postPoneCount を持つべきかどうか悩みました。振る舞いが異なる状態を型で表現するのであれば、型の名称で伝わるので、プロパティとして持たせないことにしました。プロパティが存在しないので、UnDoneTaskWithDeadline の postPoneCount が 4 以上になるといったことは起こりません。
- 振る舞いが同じだがプロパティが同一のオブジェクトが存在した場合、判定処理が必要です(例えば UnDoneTaskWithDeadline と DoneTask)。これは前回の記事でも記載しましたが、discriminated union で表現することで解決しています。
export const UnDoneTaskWithDeadline = z.object({
kind: z.literal(UNDONE_TASK_WITH_DEADLINE), // kindプロパティで判定できる
id: TaskId,
name: z.string(),
dueDate: z.date(),
});
- ワークフローの粒度, ドメインオブジェクトの名称についてはドメインの文書化時点とズレることがあり、悩みながら実装しました。いまいちまだ感覚が掴めていません。
アプリケーション層
例として postponeUseCase を紹介します。
import {
POSTPONABLE_UNDONE_TASK,
PostPonableUnDoneTask,
PostPoneTaskRepository,
TaskId,
postpone,
} from "../../domain/task/task";
import { FetchTaskQuery } from "../../infra/task/query/fetchTaskQuery";
import { TaskDto } from "./taskDto";
export const postPoneTaskUseCase =
(
fetchTaskQuery: FetchTaskQuery,
postPoneTaskRepository: PostPoneTaskRepository
) =>
async (taskId: TaskId) => {
const taskDto: TaskDto = await fetchTaskQuery(taskId);
if (taskDto.isDone) {
throw new Error("task is already done");
}
const postPonableUnDoneTask = PostPonableUnDoneTask.parse({
kind: POSTPONABLE_UNDONE_TASK,
id: TaskId.parse(taskDto.id),
name: taskDto.name,
dueDate: taskDto.dueDate,
postPoneCount: taskDto.postPoneCount,
});
const result = await postPoneTaskRepository(
postpone(postPonableUnDoneTask)
);
return await fetchTaskQuery(result.id);
};
所感
- UseCase 層はワークフローを呼び出すだけの存在として扱っています。
- ワークフローは純粋で前後に Infra 層のコードを呼び出すことで、端に依存を押しやっています。
- 関数型プログラミング的には小さい関数を合成していくので、taskId から TaskDto を取得してドメインオブジェクトに parse する処理を別の関数に切り出したほうがいいかもしれません。
インフラストラクチャ層
例として postPoneTaskRepository を紹介します。
import { PrismaClient } from "@prisma/client";
import {
POSTPONABLE_UNDONE_TASK,
PostPonableUnDoneTask,
UNDONE_TASK_WITH_DEADLINE,
UnDoneTask,
UnDoneTaskWithDeadline,
POSTPONE_COUNT_LIMIT,
} from "../../../domain/task/task";
export const postPoneTaskRepository =
(prisma: PrismaClient) => async (unDoneTask: UnDoneTask) => {
try {
if (unDoneTask.kind === POSTPONABLE_UNDONE_TASK) {
const task = await prisma.task.update({
where: { id: unDoneTask.id },
data: {
dueDate: unDoneTask.dueDate,
postPoneCount: unDoneTask.postPoneCount,
},
});
return PostPonableUnDoneTask.parse({
kind: POSTPONABLE_UNDONE_TASK,
id: task.id,
name: task.name,
dueDate: task.dueDate,
postPoneCount: task.postPoneCount,
});
} else {
const task = await prisma.task.update({
where: { id: unDoneTask.id },
data: {
dueDate: unDoneTask.dueDate,
postPoneCount: POSTPONE_COUNT_LIMIT,
},
});
return UnDoneTaskWithDeadline.parse({
kind: UNDONE_TASK_WITH_DEADLINE,
id: task.id,
name: task.name,
dueDate: task.dueDate,
postPoneCount: task.postPoneCount,
});
}
} catch (error: any) {
throw new Error(error);
}
};
所感
- ドメインオブジェクトを受け取って、データベースに永続化する際に取得したデータをドメインオブジェクトに変換して返しています。
- unDoneTask.kind === UNDONE_TASK_WITH_DEADLINE の場合は postPoneCount プロパティを持っていないので、永続化する際にドメイン層の POSTPONE_COUNT_LIMIT を参照しています。ただ、POSTPONE_COUNT_LIMIT はドメイン層では使用していないんですよね...。インフラ層にドメイン知識が漏れているわけではなさそうですがちょっとモヤってます。とはいえ、状態を型で表現するアプローチでは致し方ないのでしょうか。
プレゼンテーション層
例として postPoneTaskController を紹介します。
import { Request, Response } from "express";
import { PrismaClient } from "@prisma/client";
import { TaskId } from "../../domain/task/task";
import { postPoneTaskUseCase } from "../../application/task/postPoneTaskUseCase";
import { fetchTaskQuery } from "../../infra/task/query/fetchTaskQuery";
import { postPoneTaskRepository } from "../../infra/task/repository/postPoneTaskRepository";
export type RequestParams = {
taskId: string;
};
export const postPoneTaskController = async (
req: Request<RequestParams, {}, {}>,
res: Response
) => {
const prisma = new PrismaClient();
const result = await postPoneTaskUseCase(
fetchTaskQuery(prisma),
postPoneTaskRepository(prisma)
)(TaskId.parse(req.params.taskId));
res.status(200).json({ result });
};
所感
- トップレベルで依存関係を解決する場所ですね
その他
DTO を ドメインオブジェクト に変換する処理の複雑さは増す
下記のようにクエリサービスから取得した DTO をドメインオブジェクトに変換して、ワークフローに渡しています。
現時点では toDomain メソッドの処理は簡単ですが、要件が増えることによって複雑になることが予想されます。振る舞いによって型を分けて複雑さに対処するアプローチはドメイン層に関してはその通りなのですが、基本的なデータ型からドメインオブジェクトへの変換処理に複雑さが移動するという側面があるのではないでしょうか。もちろん、それでも得られる恩恵の方が大きいと思います。
export const completeTaskUseCase =
(
fetchTaskQuery: FetchTaskQuery,
completeTaskRepository: CompleteTaskRepository
) =>
async (taskId: TaskId) => {
try {
const taskDto: TaskDto = await fetchTaskQuery(taskId);
const task = toDomain(taskDto);
if (task.kind === DONE_TASK) return taskDto;
const result = await completeTaskRepository(complete(task));
return await fetchTaskQuery(result.id);
} catch (error: any) {
throw new Error(error);
}
};
export type TaskDto = {
id: string;
name: string;
dueDate: Date;
postPoneCount: number;
isDone: boolean;
createdAt: Date;
updatedAt: Date;
};
export const toDomain = (task: TaskDto): Task => {
if (task.isDone === false) {
if (task.postPoneCount <= 2) {
return {
kind: POSTPONABLE_UNDONE_TASK,
id: TaskId.parse(task.id),
name: task.name,
dueDate: task.dueDate,
postPoneCount: task.postPoneCount,
};
} else {
return {
kind: UNDONE_TASK_WITH_DEADLINE,
id: TaskId.parse(task.id),
name: task.name,
dueDate: task.dueDate,
};
}
} else {
return {
kind: DONE_TASK,
id: TaskId.parse(task.id),
name: task.name,
dueDate: task.dueDate,
};
}
};
責務ごとにファイルを分けるとスッキリする
今回メソッドごとにファイルを分けています。メソッド単位で責務が分かれていると捉えると、1 ファイルの中身が肥大化しないので個人的には好みです。
まとめ
「Domain Modeling Made Functional」を参考に、振る舞いに応じて型を分けることで複雑さに対処する方法を実践してみました。特に I/O 部分の実装について感触を得られることができてよかったです。要件自体はシンプルですが、実際に実装してみるとワークフローやドメインオブジェクトをどのように組み立てれば良いか迷いました。このアプローチ自体は個人的に好みなので、もっと経験を積んでいきたいと思います。
参考情報
今回参考にした情報をまとめておきます。とても参考になりました。ありがとうございます。
おわりに
明日はリサーチエンジニアの @YotaroMatsui さんの記事です。お楽しみに!
Discussion