neverthrowでドメイン駆動設計を試す - safeTryを添えて -
結論
-
safeTry()
を使用すると学習コストを抑えられるよ - 外部ライブラリを押しやれる (もしくはドメイン層のみ) なら採用してもいいかもね
はじめに
最初に興味を持ったのはこの記事だったと思います。
実装は関数型ドメインモデリングに寄せる
調査をしたところ、株式会社一休の伊藤直也さんが取り組んでいる内容を公開していましたが、実践するにはより落とし込んだ内容が必要だと感じたため、ベースとなるコードを試しながら作ってみました。
作成したCursorのルールはこちらです。0→1で指示したかったため、コード例をそのまま渡しています。まだ試せていませんが。コードが増えてきたら減らせばいいかなと思っています。そもそもneverthrowってなに?
Type-Safe Errors
TypeScriptはJavaの検査例外のような仕組みを持っておらず、エラーが発生する可能性があるかは関数の内容を見ないと判断できません。また、型も any
(コンパイラーオプションによっては unknown
) となってしまいます。エラーを戻り値に含めるのが Result
型であり、関連した様々な機能をneverthrowが提供します。関数型由来ではありますが、safeTry()
のような手続きでも自然に書ける仕組みがあり、ハードルは高くないとは思います。ですが、日本語で紹介されている記事は片手で数えられる程度しかなく、あまり知られていない印象です。Effect.gen()
のほうが有名ですかね?
参考 (あえて批判寄りの意見も):
ディレクトリ構成
データベースはSupabase + Drizzleを使用しました。
コードは「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本」をベースに使用させていただきました。「関数型ドメインモデリング」を参考にした部分も大いにあるのですが、すべてを取り入れるのは難しいと感じたため、構成自体は従来通りのレイヤードです。慣れている方であれば問題ないのかもしれませんが、私にとっては可読性を落とすだけでした。
src
├─ index.ts
└─ packages
├─ domain
│ ├─ branded.ts
│ ├─ errors.ts
│ └─ user
│ ├─ model
│ │ ├─ index.ts
│ │ ├─ user-id.ts
│ │ ├─ user-name.ts
│ │ └─ user.ts
│ └─ service
│ └─ user-exists.ts
├─ infrastructure
│ ├─ drizzle-client.ts
│ ├─ errors.ts
│ ├─ schema.ts
│ └─ user
│ ├─ delete-user-command.ts
│ ├─ index.ts
│ ├─ insert-user-command.ts
│ ├─ select-user-query.ts
│ └─ update-user-command.ts
└─ use-case
└─ user
├─ delete-user.ts
├─ errors.ts
├─ get-all-users.ts
├─ register-user.ts
└─ update-user.ts
ドメインオブジェクト
値オブジェクトでルール・独自のふるまいを定義し、
import { type Result, err, ok } from "neverthrow";
import type { Branded } from "../../branded";
import { ValidationError } from "../../errors";
export type UserName = Branded<string, "UserName">;
export const UserName = (value: string): Result<UserName, ValidationError> => {
if (value.length < 3) {
return err(new ValidationError("ユーザー名は3文字以上です。"));
}
return ok(value as UserName);
};
組み合わせてエンティティを作成します。
import type { UserId } from "./user-id";
import type { UserName } from "./user-name";
export type User = Readonly<{
id: UserId;
name: UserName;
}>;
export const User = (id: UserId, name: UserName): User => ({
id,
name,
});
export const changeUserName = (user: User, name: UserName): User => ({
...user,
name,
});
コンパニオンオブジェクトパターンを使用していますが、好みによります。伊藤直也さんはここで値オブジェクトを生成していましたが、すでに生成されたものを引数で渡すのが一般的な方法だと思います。
Readonly<>
で読み取り専用プロパティにしてください。as const
も試しましたが、明示的に記述した戻り値の型のほうが優先されるようです。あまり好きじゃない仕様だ…
class
はエラーでしか使用していません。スタックトレースを取得するために継承し、判別だけできるようにします。TypeScriptは構造的型付けですが、readonly
を使用することによって type
の型がリテラル型となり、判別されるようです。
export class ValidationError extends Error {
readonly type = "ValidationError";
}
インフラストラクチャ
各I/Oアクセスごとに個別の関数を定義します。リポジトリパターンの定義が曖昧だったため、コマンド・クエリとしました。エンティティは扱いますが、全てを1つにまとめる必要はないため、ファイルはCRUDごとに分割しています。{ cause: e }
は "target": "es2022"
で使えます。
import { eq } from "drizzle-orm";
import { ResultAsync } from "neverthrow";
import type { User } from "../../domain/user/model";
import type { DrizzleClient } from "../drizzle-client";
import { DbClientError } from "../errors";
import { usersTable } from "../schema";
export const updateUserCommand = (db: DrizzleClient) => (user: User) =>
ResultAsync.fromPromise(
db
.update(usersTable)
.set({ name: user.name })
.where(eq(usersTable.userId, user.id)),
(e) => new DbClientError("データベース接続確立エラー", { cause: e }),
);
export type UpdateUserCommand = ReturnType<typeof updateUserCommand>;
import { eq } from "drizzle-orm";
import { Result, ResultAsync, ok, safeTry } from "neverthrow";
import { User, UserId, UserName } from "../../domain/user/model";
import type { DrizzleClient } from "../drizzle-client";
import { DbClientError } from "../errors";
import { type UserDataModel, usersTable } from "../schema";
export const selectUserByIdQuery = (db: DrizzleClient) => (id: UserId) =>
ResultAsync.fromPromise(
db.select().from(usersTable).where(eq(usersTable.userId, id)),
(e) => new DbClientError("データベース接続確立エラー", { cause: e }),
).andThen((userDataModels) =>
userDataModels.length ? toModel(userDataModels[0]) : ok(undefined),
);
export type SelectUserByIdQuery = ReturnType<typeof selectUserByIdQuery>;
export const selectAllUsersQuery = (db: DrizzleClient) => () =>
ResultAsync.fromPromise(
db.select().from(usersTable),
(e) => new DbClientError("データベース接続確立エラー", { cause: e }),
).andThen((userDataModels) => Result.combine(userDataModels.map(toModel)));
export type SelectAllUsersQuery = ReturnType<typeof selectAllUsersQuery>;
const toModel = (from: UserDataModel) =>
safeTry(function* () {
return ok(User(yield* UserId(from.userId), yield* UserName(from.name)));
});
ResultAsync.fromPromise
で Promise
を ResultAsync
に変換します。全てを Result
で制御する場合、エラーをスローする可能性がある外部ライブラリはすべて neverthrow
でラップする必要があるため、面倒ではあります。このようなエラーは Result
で扱わない、という選択肢もあります。
DBクライアントは import
して使えばいいかなと思っていたのですが、トランザクションに必要 (後述) なのと、Honoで動作させると成功と失敗を交互に繰り返す症状が発生したため、カリー化して渡します。
従来の方法であればドメイン層でリポジトリの抽象を作成すると思いますが、部分適用後の型を ReturnType<>
で作成し、後述するドメインサービス・ユースケースでDIを行います。
ここで safeTry()
が出てきました。Haskellの do
記法のようなものです。ユースケースで扱うため、現時点では理解できている必要はありません。Result.combine()
でフラットにすることもできるのですが、こちらのほうが明らかに認知負荷を少なくできます。Result.combineWithAllErrors()
を使用したいなら話は別ですが。
DBクライアントは以下のようになっています。
import type { ExtractTablesWithRelations } from "drizzle-orm";
import type { PgTransaction } from "drizzle-orm/pg-core";
import type {
PostgresJsDatabase,
PostgresJsQueryResultHKT,
} from "drizzle-orm/postgres-js";
import type postgres from "postgres";
export type DrizzleClient =
| (PostgresJsDatabase<Record<string, never>> & {
$client: postgres.Sql;
})
| PgTransaction<
PostgresJsQueryResultHKT,
Record<string, never>,
ExtractTablesWithRelations<Record<string, never>>
>;
ドメインサービス
インフラストラクチャが絡む場合はコマンド・クエリを高階関数でDIし、これも部分適用後の型を作成します。今回はユーザーの重複を確認しています。
import type { SelectUserByNameQuery } from "../../../infrastructure/user";
import type { User } from "../model";
export const userExists =
(selectUserByNameQuery: SelectUserByNameQuery) => (user: User) =>
selectUserByNameQuery(user.name).map(
(duplicateUser) => duplicateUser !== undefined,
);
export type UserExists = ReturnType<typeof userExists>;
ユースケース
ユーザーを更新するケースを見てみます。ドメインサービスと同じように、関数でインフラストラクチャが絡む処理をDIします。そうすることでモックライブラリを使用せずにテストできます (関数型ドメインモデリングの181ページ前後を参照)。使用順で引数に渡すとなんとなく処理が読めますね。
import { err, safeTry } from "neverthrow";
import { UserId, UserName } from "../../domain/user/model";
import { changeUserName } from "../../domain/user/model/user";
import type { UserExists } from "../../domain/user/service/user-exists";
import type {
SelectUserByIdQuery,
UpdateUserCommand,
} from "../../infrastructure/user";
import { CanNotRegisterUserError, UserNotFoundError } from "./errors";
export const updateUser =
(
selectUserByIdQuery: SelectUserByIdQuery,
userExists: UserExists,
updateUserCommand: UpdateUserCommand,
) =>
async (id: string, name: string) => {
// ユーザーIDのバリデーション
const userIdResult = UserId(id);
if (userIdResult.isErr()) return err(userIdResult.error);
// ユーザーの存在確認
const userResult = await selectUserByIdQuery(userIdResult.value);
if (userResult.isErr()) return err(userResult.error);
const user = userResult.value;
if (user === undefined)
return err(new UserNotFoundError("ユーザーが見つかりませんでした。"));
// ユーザー名のバリデーション
const userNameResult = UserName(name);
if (userNameResult.isErr()) return err(userNameResult.error);
// ユーザー名の変更
const updatedUser = changeUserName(user, userNameResult.value);
// ユーザーの重複確認
const existsResult = await userExists(updatedUser);
if (existsResult.isErr()) return err(existsResult.error);
if (existsResult.value)
return err(new CanNotRegisterUserError("ユーザーは既に存在しています。"));
// ユーザーの更新
return await updateUserCommand(updatedUser);
};
これでも良いのですが、エラーなら早期リターンして、、とやると記述量が増えます。Gopherにしてみれば普通なのかもしれませんが。
andThen
で繋げることもできますが、あまり理解できていません。
参考:
もう一つの方法として safeTry()
というものがあります。
記事内では .safeUnwrap()
を使用していますが、自動で行われるようになりました。
safeTry
という名前が想像しやすいです。try
ではエラーがスローされると catch
されますが、safeTry
はエラーとなった場合に自動で再 return
してくれます。async
を付けることで ResultAsync
も Result
と同じように使えます (戻り値は ResultAsync
)。
import { err, safeTry } from "neverthrow";
import { UserId, UserName } from "../../domain/user/model";
import { changeUserName } from "../../domain/user/model/user";
import type { UserExists } from "../../domain/user/service/user-exists";
import type {
SelectUserByIdQuery,
UpdateUserCommand,
} from "../../infrastructure/user";
import { CanNotRegisterUserError, UserNotFoundError } from "./errors";
export const updateUser =
(
selectUserByIdQuery: SelectUserByIdQuery,
userExists: UserExists,
updateUserCommand: UpdateUserCommand,
) =>
(id: string, name: string) =>
safeTry(async function* () {
// ユーザーIDのバリデーション
const userId = yield* UserId(id);
// ユーザーの存在確認
const user = yield* selectUserByIdQuery(userId);
if (user === undefined) {
return err(new UserNotFoundError("ユーザーが見つかりませんでした。"));
}
// ユーザー名のバリデーション
const userName = yield* UserName(name);
// ユーザー名の変更
const updatedUser = changeUserName(user, userName);
// ユーザーの重複確認
const exists = yield* userExists(updatedUser);
if (exists) {
return err(
new CanNotRegisterUserError("ユーザーは既に存在しています。"),
);
}
// ユーザーの更新
return updateUserCommand(updatedUser);
});
今回はすべてエラーを流していますが、yield*
を使わなければ処理を続行させることはもちろん可能です。黒魔術感が強いですが、TypeScriptで表現しようと思うとこれが限界なのでしょう。
形さえ覚えてしまえば記述量が減らせるため、個人的には積極的に使いたいと思っています。
使い方 (プレゼンテーション)
Honoを使用しました。動くかどうか確かめるだけなので直打ち、レスポンスも適当です。
トランザクションはユースケースで張るのが一般的だと思いますが、プレゼンテーションのほうがI/Oを端に追いやる関係で都合が良いと考えます。トランザクションをする必要があるかどうかがわからないためユースケースのほうが良いとは思うのですが。何か方法があればご教示ください。Drizzleのトランザクションはエラーがスローされることによって行われるのも厄介です。
async
は これは非同期関数に変換できます。ts(80006)
と言われたので付けました。await
はなくても動いています。これでいいのか…?
関連:
import { drizzle } from "drizzle-orm/postgres-js";
import { Hono } from "hono";
import postgres from "postgres";
import { env } from "../env";
import { userExists } from "./packages/domain/user/service/user-exists";
import type { DrizzleClient } from "./packages/infrastructure/drizzle-client";
import {
selectUserByIdQuery,
selectUserByNameQuery,
updateUserCommand,
} from "./packages/infrastructure/user";
import { updateUser } from "./packages/use-case/user/update-user";
const app = new Hono<{ Variables: { db: DrizzleClient } }>()
.use(async (c, next) => {
c.set("db", drizzle({ client: postgres(env.DATABASE_URL) }));
await next();
})
.get("/tx", async (c) => {
return c
.get("db")
.transaction((tx) =>
updateUser(
selectUserByIdQuery(tx),
userExists(selectUserByNameQuery(tx)),
updateUserCommand(tx),
)("id", "name").match(
(value) => c.json(value),
(error) => {
throw error;
},
),
)
.catch((error) => c.json(error));
});
export default app;
エラーの型を見てみると、
どのエラーが発生しうるかがわかります。スローすると失いますが…
トランザクションを使用しないならこう。
import { drizzle } from "drizzle-orm/postgres-js";
import { Hono } from "hono";
import postgres from "postgres";
import { env } from "../env";
import { userExists } from "./packages/domain/user/service/user-exists";
import type { DrizzleClient } from "./packages/infrastructure/drizzle-client";
import {
selectUserByIdQuery,
selectUserByNameQuery,
updateUserCommand,
} from "./packages/infrastructure/user";
import { updateUser } from "./packages/use-case/user/update-user";
const app = new Hono<{ Variables: { db: DrizzleClient } }>()
.use(async (c, next) => {
c.set("db", drizzle({ client: postgres(env.DATABASE_URL) }));
await next();
})
.get("/db", (c) => {
return updateUser(
selectUserByIdQuery(c.get("db")),
userExists(selectUserByNameQuery(c.get("db"))),
updateUserCommand(c.get("db")),
)("id", "name").match(
(value) => c.json(value),
(error) => c.json(error),
);
})
export default app;
こちらは .type
が文字列リテラルのユニオン型になるため、switch
文も使えます。
おわりに
関数型のエッセンスはわかりませんが、これくらいであれば難しくはないかなと思います。
皆さんもコーディングの方針を定めてみてください。
状態遷移を型で表現するなど、まだ触れられていない部分は後々。
Discussion