ここ半年で技術書を結構読むことになったことや、個人開発をゆるゆる始めたことからタスク管理の必要性を感じ始めました。
タスク管理自体は雑ではありますがGoogleカレンダーでやってはいたので、じゃあGoogleカレンダーで本格的にタスク管理するかーということで色々カスタマイズしながら使っていました。
ただ、実際に運用すると痒いところに手が届かなかったり、自分の考え方とぴったし対応するようなタスク管理ができずモヤモヤが蓄積していきました。
また、自分は目標を設定しそれに紐づけてタスク管理したいという強い想いがあり、そこを実現するタスク管理サービスがGoogleカレンダー以外にないか探しましたがありませんでした。
シンプルなものだとあるんですが、本当に細かいところまで自分にとって考慮されているものはありませんでした。
そこで自分専用のタスク管理システムを作れば問題解決できるのでは?という発想に至り、自分に最適化されたタスク管理システムを開発しました。
また個人開発では自分が普段使ってない技術を最低2つ以上は入れるというマイルールがあるため、自分の興味のある技術を今回も試してみました。具体的には以下。
今回は関数型ドメインモデリングに焦点を当てます。
また、関数型ドメインモデリングを実践していくにあたり以下を参考にしました。どちらも大変参考にさせて頂きました。
まず良かった点です。ざっくり3つほどあります。
書籍を読み「ドメインイベントを軸にドメインモデルを生み出し、それを型として表現することが重要である」ということが、数ある中でも重要度が高いと自分は理解しました。
ドメインイベントというのはビジネス上の出来事において「〜された」のような「状態の変化」を起こすもののことを言います。
これは関数型ドメインモデリングではなくDDDの考え方の1つですね。
など、状態が変化されたもの全般を指します。
自分は今までクラスを使った、俗にいうオブジェクト指向的な設計をすることが多かったのですが(オブジェクト指向の定義は複雑すぎる(?)ので一旦置いておいて)、そうすると大体以下のような記述になるかなと思います。
※もちろんやり方は他に色々あると思いますが一例として
1class Task {
2 id: TaskId | string | undefined;
3 title: Title | string;
4 status: Status | string;
5 categoryId: CategoryId | string;
6}
しかしこれだと色々問題が起きてくるんですよね。
例えばユーザーがUI上で入力したときはもちろんidなんてものはないのでidはundefinedになると思います。
ただ、型としてはstringも指定できるので仕様として間違ったオブジェクトが生成されてしまう余地があります。
そこでこうします。
1// バリデーションされていないタスク
2type UnvalidatedTask = {
3 type: "Unvalidated";
4 title: string;
5 status: string;
6 categoryId: string;
7};
8
9// バリデーション済みのタスク
10type ValidatedTask = {
11 type: "Validated";
12 title: Title;
13 status: Status;
14 categoryId: CategoryId;
15};
16
17// 作成されたタスク
18type CreatedTask = {
19 type: "Created";
20 id: TaskId
21 title: Title;
22 status: Status;
23 categoryId: CategoryId;
24};
こうすることで、ドメインイベントにおける状態を型として表現することできました。
例えば、idはCreatedTaskで初めて登場しています。「idはタスクが作成されてから作られるもの」という仕様が厳密に型として表現することができました。
仕様が厳密に型として表現されることで「1つのクラスに対しいろんな値の組み合わせがあって〜」のような曖昧な表現がなくなります。
状態遷移を型として定義してから、型と仕様が1対1で対応させやすくなったのでバグが入りにくくなりました。
最初はいっぱい型書くなーと思ってましたが、実際に運用してみると不正値の混入懸念が従来に比べてかなり軽減されました。
コンパイルが通れば「ドメインロジックの部分に関しては少なくともきちんと動くだろう」と自信を持つことができるようになりましたね。
参考にしたスライドでも話がありましたが、自分も例外処理はドメインイベントのように型として表現したくなりました。
というのもTypeScriptというかJavaScriptでは例外が発生すると、大域脱出してせっかく組み上げてきた状態遷移が消えてそこのリカバリーをするのが結構大変で、どこでどうエラーを処理するかを考えるのは認知負荷が高いです。
そこでneverthrowのResult型を使うようにしました。こちらは参考スライドがより詳細に説明していると思います。
Rustなんかには標準でResult型がありますが、TypeScriptにはないので自作するかライブラリを導入する必要があります。
1export type validateTask = (
2 unvalidatedTask: UnvalidatedTask,
3) => Result<ValidatedTask, ValidationError>;
4
5export const validateTask: validateTask = (unvalidatedTask) => {
6 // 本当は値オブジェクトに詰め替える処理がある
7 return ok({
8 type: "Validated",
9 // ... 省略
10 });
11};
こんな感じで、validateTask関数の中ではValidationErrorが発生する可能性があるので、大域脱出しないようにValidationErrorをResultで返します。
こうすることで、例外を発生させることなく例外を型として表現し処理させることが可能になります。
ちなみにですが非同期処理の場合はResultAsyncというものがあり、今回のタスク管理システムでも多用しています。
neverthrowというかJSにおけるResultの活用には色々な意見があります。どれだけResultでガードしてもサードパーティで例外が発生したりして、思わぬところですり抜けたりしてしまう等。
ただ、そこは設計でカバーすればある程度問題なくなるかなと思ったので、今回はResult型を採用しました。
チーム開発だともっと慎重にpros,cons考えないとダメでしょうけど…
今回やってみて、I/Oとドメインロジックを綺麗に分離できていいなと思いました。
I/Oをドメインロジックと分離することで単体テストと統合テストをしやすくなりましたね。
とはいえ純粋関数で組み上げたドメインロジックの中でI/Oを発生せざるを得ないユースケースは多々あり、自分のシステムでも往々にしてありました。
一部コード例を記載していることからわかるように、ドメインモデルの状態を遷移させていく処理は純粋関数であり、I/O処理は組み込んでいません。
厳密にはワークフローの中でI/Oの処理は走るのですが、型としてはI/Oがないようにしました。これはスライドと書籍の両方を参考にしました。
1// --------------------------------------------- //
2// --------------------------------------------- //
3// ドメインサービス層に相当
4
5// インターフェースの役割
6export type checkTicketExistsAndGet = ({
7 ticketId,
8}: { ticketId: TicketId }) => ResultAsync<Ticket, NotFound | ValidationError>;
9
10// Supabaseでチケットの存在チェックとチケットエンティティを返す関数。上の関数の実装詳細。
11export const checkTicketExistsAndGet =
12 (supabase: SupabaseClient<Database>): checkTicketExistsAndGet =>
13 ({ ticketId }) => {
14 const queryPromise = supabase
15 .from("tickets")
16 .select(
17 `
18 id,
19 title,
20 status,
21 memo,
22 user_id
23 `,
24 )
25 .eq("id", ticketId)
26 .single();
27// ...省略
28// --------------------------------------------- //
29// --------------------------------------------- //
30
31// --------------------------------------------- //
32// --------------------------------------------- //
33// ワークフロー
34type Workflow = (
35 model: UnvalidatedTicket,
36) => ResultAsync<UpdatedTicket, UpdateTicketError>;
37export const updateTicketTitleWorkflow =
38 // インターフェースを引数の型として参照
39 (checkTicketExistsAndGet: checkTicketExistsAndGet): Workflow =>
40 (model) => // 省略
41// --------------------------------------------- //
42// --------------------------------------------- //
checkTicketExistsAndGetは、Supabaseクライアントを使って実際のDBからチケットの取得を行っているのでI/Oが絡む処理です。
ただ、これを実際のワークフローにそのまま組み込んでしまうと、せっかく純粋関数で状態遷移をさせてきたのに副作用が入ってしまいます。
もちろん、バリデーション関数でチケットの存在チェックをすること自体からは逃れられないので、ワークフローの中でI/O処理はさせるのですが、型としてはカリー化の部分関数適用で避けることができています。
ちなみに、実際の呼び出し元はこんな感じになっています。
1// プレゼンテーション層に相当
2export const ticketTitleUpdateRouter = trpcContext.router({
3 update: trpcContext.procedure
4 .input(Input)
5 .mutation(async ({ input }): Promise<TrpcMutationResponse<undefined>> => {
6 // データソースのDIは簡略化のため今回はしない
7 const supabase = await createServerClient();
8 const workflow = updateTicketTitleWorkflow(
9 checkTicketExistsAndGet(supabase),
10 );
11 const result = toUnvalidatedTicket({
12 ticketId: input.ticketId,
13 newTitle: input.newTitle,
14 })
15 .asyncAndThen(workflow)
16 // ... 省略
17 }),
18});
今回の場合は、tRPCのルーティングからSupabaseの実装詳細であるcheckTicketExistsAndGetをupdateTicketTitleWorkflowに渡してあげればOKです。
こうすることで純粋関数を保ったままにすることができ、テストする際はモックを用意して単体テストの共有依存を防いだり速度を落とさないようにしたり等、テスト容易性が向上しました。
個人的にあまりなく、強いていうならこの考え方と実装をチームに導入するのは中々ハードルが高いなと感じました。チームメンバー数がわりかし多いところは特に難しいのかなと…
「1番コアではないがユーザーに割と使われている機能で、かつそれなりに複雑な業務ロジック」でフィーチャーフラグ等を使い部分的に試してみるのはアリなんじゃないかなとは思いました。
個人的にはこの手法はすごく開発がしやすく、型に強く支えられながら書くことができるので、今大丈夫かな?みたいな不安を感じる場面がかなり少なかったです。
全てはトレードオフなのでこれが絶対絶対良いというわけではないですが、本業のドメイン領域とこの手法は結構相性いいなと思ってるので、部分部分で試していきたいくらいには感触が良かったですね。