Hono、いいですよね。
軽いし速いし、TypeScriptとの相性もかなり良いです。
特にHono RPCはかなり気持ちがよく、TypeScriptでフロントエンドを書く限り使わない理由がほぼ無いです。
しかし、ここで油断すると設計を間違えます。
「型が繋がる」ことと「全部を同じ型で書く」ことは違います。
変更したら壊れてほしいところが壊れるようにするのが正しい設計です
逆に言えば、変更したのに壊れてほしいところが壊れない設計は怖い。
変更したら関係ないところまで壊れる設計も怖い。
この感覚だけ持っておけば、クリーンアーキテクチャはかなり実用的になります。
クリーンアーキテクチャは依存方向の話
クリーンアーキテクチャというと、円の図とか、UseCaseとかRepositoryとか、謎のinterfaceが大量発生する印象があります。
正直、過剰にやると普通にしんどいです。
ただし、中心にある考え方はかなりシンプルです。
どのモジュールが、どの実装を知っていていいのか
これだけです。
例えば、ベタですがユーザーを作成する処理があるとします。
- DBはDrizzleかPrismaか生SQLか
- APIはHonoかFastifyかNext.js Route Handlerか
- フロントエンドはReactかVueか
これらは別々の理由で変わります。
DBの都合でAPIレスポンスが変わってほしくない。
APIフレームワークの都合でビジネスロジックが変わってほしくない。
フロントエンドの表示都合でDBスキーマが変わってほしくない。
つまり、全部同じ型で貫通させると気持ちよく見えて、後からつらくなることがあります。
HonoのAppTypeはAPI境界の抽象
Hono RPCでは、サーバー側で定義したrouteからAppTypeをexportして、クライアント側でhc<AppType>()に渡します。
公式ドキュメントでも、validatorで指定した入力型と、c.json()で返した出力型をHono Clientが推論できると説明されています。
// server/routes/users.ts
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
export const userRoutes = new Hono().post(
'/users',
zValidator('json', createUserSchema),
async (c) => {
const input = c.req.valid('json')
return c.json(
{
id: 'user_123',
name: input.name,
email: input.email,
},
201,
)
},
)
export type UserAppType = typeof userRoutes
// client/api.ts
import { hc } from 'hono/client'
import type { UserAppType } from '../server/routes/users'
export const userApiClient = hc<UserAppType>('/api')
これでリクエストもレスポンスも型がつきます。
最高です。
ここで重要なのは、UserAppTypeは「Honoの具象実装を共有している」のではなく、APIの境界を型として共有しているということです。
AppType自体は抽象です。
クライアントはHonoサーバーの実装を知りません。
知っているのは、どのpathに、どのmethodで、どんな入力を渡すと、どんなレスポンスが返るかです。
これはかなり健全です。
Req/Resの型を手書きしてはいけない
APIの型を手書きするのは危険です。
type CreateUserRequest = {
name: string
email: string
}
type CreateUserResponse = {
id: string
name: string
email: string
}
一見よさそうに見えます。
でも、これがAPIの実装とズレた瞬間に終わりです。
例えば、サーバー側がこう変わったとします。
return c.json(
{
id: 'user_123',
profile: {
name: input.name,
},
},
201,
)
手書きのCreateUserResponseは勝手には壊れません。
壊れてほしいのに壊れない。
最悪です。
しかもJSONのシリアライズは普通に難しいです。
-
Dateは文字列になる -
undefinedは消えることがある -
bigintはそのままJSONにできない -
MapやSetは期待通りにならない - class instanceはただのobjectになる
JSONのシリアライズ仕様を完全にマスターしている人間、たぶんいません。
少なくとも僕は無理です。
だから、APIのReq/Res型を手書きするのではなく、validatorとc.json()から推論させるのが正しいです。
import type { InferRequestType, InferResponseType } from 'hono/client'
type CreateUser = typeof userApiClient.users.$post
type CreateUserRequest = InferRequestType<CreateUser>
type CreateUserResponse = InferResponseType<CreateUser, 201>
APIの形を変えたら、APIクライアントを使っているところが型エラーになります。
これが嬉しいのです。
壊れてほしいところが壊れているからです。
でもusecaseがHonoを知っていたらまずい
一方で、Hono RPCが便利だからといって、usecaseやdomainにHonoの型を持ち込むのはかなり危険です。
import type { Context } from 'hono'
export const createUserUseCase = async (c: Context) => {
const body = await c.req.json()
// ユーザー作成処理
}
これはつらい。
このusecaseは、Honoがないとテストできません。
CLIから呼びたいだけでもHonoのContextを用意する必要があります。
バッチ処理に流用したいだけでもHonoがついてきます。
ビジネスロジックがWebフレームワークに依存している状態です。
こういうのは、後からじわじわ効いてきます。
最初は速いです。
でも、画面が増えたりした時に急に苦しくなります。
usecaseはHonoを知らない方がいいです。
TypeScriptなら、別にclassにしなくても関数で十分です。
export type CreateUserInput = {
name: string
email: string
}
export type CreateUserOutput = {
id: string
name: string
email: string
}
export const createUserUseCase =
(deps: { userRepository: UserRepository }) =>
async (input: CreateUserInput): Promise<CreateUserOutput> => {
const user = await deps.userRepository.create({
name: input.name,
email: input.email,
})
return {
id: user.id,
name: user.name,
email: user.email,
}
}
Hono側はadapterです。
export const userRoutes = new Hono().post(
'/users',
zValidator('json', createUserSchema),
async (c) => {
const input = c.req.valid('json')
const createUser = createUserUseCase({
userRepository: createDrizzleUserRepository(db),
})
const result = await createUser({
name: input.name,
email: input.email,
})
return c.json(result, 201)
},
)
この形なら、usecaseはHonoを知りません。
HonoをElysiaに変えることになっても、usecaseは基本的に無傷です。
意味が違うなら型を分ける
よくある失敗が、DBの型を全部に使い回すことです。
type User = typeof users.$inferSelect
これをAPIレスポンスにも、usecaseにも、Reactコンポーネントにも渡す。
気持ちはわかります。
でも、DBの型と画面の型は意味が違います。
例えばDBにはこういう値があるかもしれません。
passwordHashdeletedAtcreatedAtupdatedAtinternalMemo
これをAPIレスポンスやコンポーネントが知っているのは...たぶんよくありません。
firestoreなどを利用していてクライアントが直接データを取得できるとしても、コンポーネントがDBの都合を知りすぎているのは微妙です。
それぞれの機能で必要な型は違います。
// DBが知っている型
type UserRecord = {
id: string
email: string
name: string
passwordHash: string
deletedAt: Date | null
createdAt: Date
updatedAt: Date
}
// ビジネスロジックが知っている型
type UserEntity = {
id: string
email: string
name: string
isDeleted: boolean
}
// APIが返す型
type UserResponse = {
id: string
email: string
name: string
}
// フロントエンドのコンポーネントが必要な型
type UserListItemProps = {
displayName: string
email: string
}
全部を別々に定義しろという話ではありません。
過剰に分けすぎるのも普通に悪いです。
大事なのは、同じ理由で変わるものかどうかです。
インターフェース抽象は必要最低限でいい
クリーンアーキテクチャをやろうとして、何でもinterfaceにすると死にます。
export interface UserNameFormatterInterface {
format(userName: string): string
}
いや、それ関数でよくない?
抽象が必要なのは、差し替えたい境界です。
例えばusecaseから見たrepositoryは抽象にする価値があります。
export type CreateUserRepositoryInput = {
name: string
email: string
}
export type UserRepository = {
create(input: CreateUserRepositoryInput): Promise<UserEntity>
}
こうすればusecaseはDB実装を知らないで済みます。
repository実装はDrizzleを知っています。
export const createDrizzleUserRepository = (db: Database): UserRepository => ({
create: async (input: CreateUserRepositoryInput): Promise<UserEntity> => {
// Drizzleでinsertする
// DBの型をUserEntityに変換して返す
throw new Error('not implemented')
},
})
この依存方向が大事です。
usecaseがcreateDrizzleUserRepositoryを直接知っていると、DB実装の変更でusecaseが壊れます。
それは壊れてほしくない。
一方で、createDrizzleUserRepositoryがDBスキーマの変更で壊れるのは正しいです。
そこは壊れてほしい。
判断基準は「ここを変えたらエラーになってほしいか」
設計判断で迷ったら、これだけ考えればいいです。
ここを変えたら、どこが型エラーになってほしいか?
例えばAPIレスポンスからemailを消すとします。
return c.json(
{
id: user.id,
name: user.name,
},
200,
)
この時、フロントエンドでdata.emailを表示している箇所は壊れてほしい。
API contractが変わったからです。
でも、usecaseが壊れてほしいかというと微妙です。
usecaseにとって、APIでemailを返すかどうかは本質ではないかもしれません。
逆に、ビジネスルールとして「メールアドレスは必須ではなくなる」という変更なら、usecaseは壊れてほしい。
そのルールを扱っているからです。
DBのusers.emailをnullableにしたなら、repositoryは壊れてほしい。
でも、ReactコンポーネントがDBのnullable事情で直接壊れるのは距離が近すぎるかもしれない。
この感覚です。
一緒に変わるものは密に、別々に変わるものは疎に
設計の話は、すぐに「疎結合こそ正義」みたいになりがちです。
でも、全部を疎にすると不便極まりないです。
一緒に変わるものは密でいいです。
Honoのroute、validator、c.json()のレスポンスはかなり近いです。
API境界として一緒に変わります。
だから密にして、AppTypeでクライアントまで型を流すのは合理的です。
一方で、usecaseとHonoのContextは別々に変わります。
だから疎にする。
フロントエンドのコンポーネントpropsとAPI responseも、場合によっては別々に変わります。
一覧表示に必要な値だけ渡すなら、componentのinterfaceを分ける。
まとめ
Hono時代のクリーンアーキテクチャは、昔ながらのレイヤー分割をそのまま持ち込むべきではないと思っています。
それに、最近はフルスタックWebフレームワークの存在がHTTP境界をより意識させない方向に進化しています。
Hono RPCとvalidatorを使えば、API境界のI/Oはかなり強く型付けできます。
これは積極的に使うべきです。
ただし、Honoの便利さをusecaseやdomainに持ち込むと苦しくなります。
ビジネスロジックはフレームワークを知らない方がいい。
合言葉は ここを変えたらエラーになってほしいか? です。
そのために、DBの型、ビジネスロジックのデータ型、APIのスキーマ、フロントエンドのコンポーネントが使うinterfaceを、必要最低限で分けましょう。
一緒に変わるものは密に。
別々に変わるものは疎に。
