設計違反が型エラーになるバックエンド — Capability Token・純粋関数 DDD・クリーンアーキの全体像
Published on 2026年6月28日
アーキテクチャはじめに
バックエンドの認可バグはレビューで防ぐのが一般的だ。「ここに labelId の WHERE が抜けている」「認可チェックを飛ばしている」という指摘をコードレビューで入れる。しかしレビューは確率的な防御であって、構造的な封じ込めではない。
本記事では、音楽レーベル/アーティストの作品(音源・MV・ジャケット・ライナーノーツ)を管理するサービスを題材に、Next.js のモノリス内に Hono バックエンドを埋め込んだ構成を例として、「認可の実装漏れ」「テナント(レーベル)スコープの付け忘れ」「書き込みのトランザクション境界の抜け」が型エラーとして検出される設計を解説する。
ここでのマルチテナントの単位は「レーベル(Label)」だ。1 つのレーベルが複数のアーティストと作品(Work)を抱え、レーベルのスタッフ(LabelMember)がロールに応じて作品を編集する。
この記事の位置づけ
この記事は、これまで個別に書いてきた設計の話をひとつのバックエンド実装に統合し、各関心ごとの制御がどう噛み合うかの全体像をまとめたものだ。個々のパターンの背景や単体の解説は、過去記事で扱っている。
- クリーンアーキテクチャにおける依存関係の集約 〜 Composition Root パターンの導入 — DI コンテナと依存の集約
- TypeScript でクロージャを使った真のカプセル化を実現する Entity 設計 — class を使わない純粋関数 DDD
- DDD やってみた / オブジェクトとドメインモデルの違い — ドメインモデルの考え方
- Hono Context / Request の仕組みとフレームワーク間の互換性 — Hono を採用する理由
- 土台と上物を分ける:フレームワーク・ランタイム・言語の交換可能性を前提にした設計 — レイヤー構造とフレームワーク非依存
- エラーハンドリングの設計:「何が起きたか」と「どう返すか」を分離する — 層をまたぐ責務分離
- テーブルは自分のことだけを知っていればいい ― 中間テーブルと責務の分離 — テーブル設計と多態集約
- テスト戦略:I/O 契約を守り、内部実装を自由にする — 型回帰テストと設計の固定
- モノレポ × モノリスという選択 / 個人プロダクトのモノレポプロジェクト全体像について — モノレポ・BFF の構造
以降では、これらの構造を土台にアーティスト作品管理サービスの実装へ落とし込み、認可・レーベルスコープ・トランザクション・ロック・冪等性といった関心ごとがどこで・どう制御されるかを一枚の絵として描く。
全体のレイヤ構造
presentation (Hono)
│
▼
usecases ──▶ domain/services(純粋関数・横断 read model 組み立て)
│ │
│ ▼
│ domain(Work 集約: entities / behaviors / factories
│ / valueObjects / policies / repositories[IF])
▼
infrastructure(repositories 実装 / container / db / storage)
依存は常に内向き。domain はフレームワーク非依存の純粋関数で、class を使わずクロージャでカプセル化している。
各層の責務をひと言で言うとこうなる。
| 層 | 何をするか |
|---|---|
| presentation | 認証・Zod バリデーション・routing |
| usecases | ドメイン状態の構成。I/O シェル |
| domain/services | 複数集約を横断する純粋な読み取りモデル組み立て |
| domain | 集約・VO・純粋関数。HTTP も DB も知らない |
| infrastructure | DB 実装・scoped repository・Composition Root |
Work(作品)集約と純粋関数 DDD
なぜ class を使わないか
TypeScript の class は new による生成・instanceof による判定・継承が前提になる。しかしドメインロジックに必要なのは「状態を持つオブジェクト」ではなく「状態から振る舞いを返す関数」だ。この設計の動機と単体の解説は クロージャを使った真のカプセル化を実現する Entity 設計 に詳しく書いた。本記事ではそれを Work(作品)集約として実装に落とし込む。
Work は「音源・MV・ジャケット画像といったメディア作品」と「歌詞・ライナーノーツといったテキスト作品」を 1 つの集約として扱う。クロージャによる実装はこうなる。
// domain/works/behaviors/index.ts
export const createWorkBehaviors = (state: WorkState): Work => {
// 配信サービス(DSP)から同期した作品はタイトル編集不可。このルールはここが唯一の出所。
const titleEditable = !isStreamingLinkKind(state.kind)
return {
getId: () => state.id,
getTitle: () => state.title.value,
isTitleEditable: () => titleEditable,
changeTitle: (newTitle) => {
if (!titleEditable) throw createTitleNotEditableError()
// 新しい Work を返すイミュータブル設計
return createWorkBehaviors({ ...state, title: createWorkTitle(newTitle) })
},
isMedia: () => state.payload.type === 'media',
isNote: () => state.payload.type === 'note',
isEncoding: () => state.payload.type === 'media' && state.payload.processingStatus === 'processing',
hasLyrics: () =>
state.payload.type === 'media' && isMediaKind(state.kind) && (state.payload.lyrics?.lines.length ?? 0) > 0,
getPayload: () => state.payload,
}
}
状態(state)はクロージャに閉じ込められ外から触れない。changeTitle は新しい Work を返すイミュータブル設計になっている。「DSP から同期した作品はタイトルを編集できない」というドメインルールも、titleEditable という一点に閉じている。
payload は判別可能ユニオンで種別を分離する
作品には「音源・MV・ジャケットなどのメディア系」と「歌詞・ライナーノーツなどのテキスト系」があり、種別固有の中身を payload として分離する。音源・MV はエンコード処理(processingStatus)や歌詞(lyrics)を持ち、配信リンク(streaming)も保持する。
// domain/works/entities/index.ts
export type LyricLine = { atMs: number; text: string }
export type MediaPayload = {
type: 'media'
extension: string | null
sizeKB: number | null
processingStatus: 'processing' | 'completed' | null
lyrics: { lines: LyricLine[] } | null
streaming: { url: string; description: string } | null
}
export type NotePayload = {
type: 'note'
body: string // ライナーノーツ / クレジット本文
}
export type WorkPayload = MediaPayload | NotePayload
export type WorkState = {
readonly id: string
readonly labelContext: LabelContext
readonly kind: WorkKind // 'track' | 'musicVideo' | 'artwork' | 'streamingLink' | 'note'
readonly title: WorkTitle
readonly releaseId: string | null // 所属するリリース(アルバム/EP)
readonly genres: GenreRef[]
readonly credits: ArtistCreditRef[] // 参加アーティスト・クレジット
readonly payload: WorkPayload
}
genres(ジャンル)と credits(参加アーティストのクレジット)は作品共通のメタデータとして集約に持たせる。releaseId は作品がどのリリース(アルバム・EP)に属するかを表す。
DB の多態集約点をドメインに登場させない
DB のテーブル設計では item が note・media 双方の親レコードを持つ多態集約点になっている。テーブルが自分の責務だけを持つべきという考え方は テーブルは自分のことだけを知っていればいい で書いたとおりだ。しかし Item をドメインオブジェクトとして公開すると、業務ロジックが「item.type を見て分岐」するコードで溢れる。
item.type の分岐は workRepository(mapper)の内部に閉じ込め、集約ルートとして Work のみを外に返す。
// infrastructure/repositories/workRepository/index.ts
async findDetail({ itemId }): Promise<Work | null> {
const row = await db.query.item.findFirst({ ... })
if (!row) return null
// item.type の分岐はここだけ。業務ロジックには露出しない。
if (row.type === 'note' && row.note) {
return reconstructWork({
...common,
kind: 'note',
payload: { type: 'note', body: row.note.body ?? '' },
})
}
if (row.type === 'media' && row.media) {
return reconstructWork({
...common,
kind: row.media.kind, // 'track' | 'musicVideo' | 'artwork' | 'streamingLink'
payload: { type: 'media', extension: row.media.extension, ... },
})
}
return null
}
usecase は work.isNote() / work.isMedia() を呼ぶだけで、item.type という永続化の都合を知らなくていい。DB 上は多態テーブル item、ドメイン上は単一集約 Work ── 永続化の都合とドメインの語彙を意図的にずらしている。
認可設計:Capability Token パターン
問題:認可の付け忘れは型エラーにならない
典型的な実装では認可チェックが任意になっている。
// ❌ 認可を飛ばしてもコンパイルが通る
app.get('/:id', async (c) => {
const repo = new WorkRepository(db) // 認可なしに repo が手に入る
return c.json(await repo.findDetail(c.param('id')))
})
解決:repository は認可を通じてしか手に入らない
Composition Root(container)は authorizeReadRequest / authorizeWrite しか公開しない。生の repository は外から到達できない。Composition Root に依存を集約する考え方そのものは クリーンアーキテクチャにおける依存関係の集約 で扱っている。ここではそれを「認可を通さないと repository に到達できない」入口に拡張する。
// infrastructure/container/index.ts
export const getAuthorizationDeps = (() => {
let deps: AuthorizationDeps | null = null
return (): AuthorizationDeps => {
if (!deps) {
const db = getDb()
deps = {
membershipReader: createLabelMembershipRepository(db),
buildReadCapabilities: (actor) => ({ actor, ...buildScopedRepos(actor, db) }),
// write は必ず tx 内で実行される
runWithWriteCapabilities: (actor, work) =>
db.transaction((tx) => work({ actor, ...buildScopedRepos(actor, tx) })),
}
}
return deps
}
})()
authorizeReadRequest を通ると ReadCapabilities が返る。ReadCapabilities は reader のみ(write メソッドが型に存在しない)。authorizeWrite を通ると WriteCapabilities が返り、こちらは repository(read + write)を持つ。
// usecases/authorization/capabilities.ts
export type ReadCapabilities = {
actor: LabelMember
works: IWorkReader // findRef / findDetail のみ
favorites: IFavoriteReader // スタッフのお気に入り
genres: IGenreReader
mediaStorage: IMediaStoragePort
}
export type WriteCapabilities = {
actor: AuthorizedActor
works: IWorkRepository // read + saveTitle / updateNotesBody / touch
favorites: IFavoriteRepository
genres: IGenreRepository
mediaStorage: IMediaStoragePort
}
読み取り認可で書き込みメソッドを呼ぼうとすると型エラーになる。これを型回帰テストで固定している。型を契約として扱い回帰で守る方針は テスト戦略:I/O 契約を守り、内部実装を自由にする と同じ考え方だ。
// usecases/authorization/capabilities.test.ts
it('forbids write methods through ReadCapabilities', () => {
const typeOnly = (read: ReadCapabilities) =>
// @ts-expect-error 読み取り認可では saveTitle は型に存在しない
read.works.saveTitle
expect(typeof typeOnly).toBe('function')
})
labelId スコープも by-construction
scoped repository は actor をクロージャに閉じ込める。labelId を引数で受け取らないので、新しいメソッドを足しても WHERE への追加を忘れない構造になっている。あるレーベルのスタッフが別レーベルの作品に触れることは、構造上起こり得ない。
export const createScopedWorkRepository = (db: DbClient, actor: LabelMember): IWorkRepository => {
const labelId = actor.labelId // 一度だけ取り出してクロージャに閉じる
return {
async findDetail({ itemId }) {
return db.query.item.findFirst({
where: (i, { and, eq }) => and(eq(i.id, itemId), eq(i.labelId, labelId)),
// labelId は全メソッドで自動的に効く
})
},
}
}
Capability Token による偽造防止
LabelMember / AuthorizedActor は brand symbol でタグ付けされており、domain/labelMembership/authorization の中でしか mint できない。
declare const memberBrand: unique symbol // このモジュール外から参照不能
declare const permitBrand: unique symbol
export type LabelMember = {
readonly [memberBrand]: true
readonly labelId: string
readonly userId: string
readonly roleType: RoleType // 'owner' | 'editor' | 'viewer' など
}
// mint できるのはここの関数だけ
export const authorizeLabelMembership = (scope, membership): LabelMember => {
if (membership === null) throw createForbiddenError()
return { labelId: scope.labelId, userId: scope.userId, roleType: membership.roleType } as LabelMember
}
as LabelMember のキャストがこのモジュールにしか存在しないため、認可関数を通さないと actor トークンを作れない。
クエリ設計:最小・再利用・スコープ保証
repository のメソッドは操作の記述だけ
業務ルールをクエリに持ち込まない。repository が持つべき操作はこれだけで十分だ。
findRef(id) → 軽量参照(kind の判定のみ)
findDetail(id) → 集約フル復元
list(filter?, sort?) → 一覧
save(entity) → 保存
delete(id) → 削除
touch(id) → updatedAt の更新
findDetail と findSummary の 2 種が揃えば一覧の過剰取得も解決できる。作品一覧は findSummary(タイトル・サムネイル・kind 程度の軽量 projection)、作品詳細画面は findDetail(歌詞やクレジットまで含むフル)という使い分けだ。
ソートが「業務ロジック」になる条件
「常に不変か、文脈で変わるか」で判断が分かれる。
// 不変のソート:クエリに閉じてよい
async list(): Promise<GenreRef[]> {
return db.query.genre.findMany({
where: (genre, { eq }) => eq(genre.labelId, labelId),
orderBy: (genre, { asc }) => [asc(genre.name)], // ジャンルは常に名前昇順
})
}
// 文脈依存のソート:外から受け取る(リリース日順 / 更新日順 / タイトル順 など)
async list(orderBy: OrderBy): Promise<WorkSummary[]>
トランザクション境界:書き込みは必ず tx 内で走る
runWithWriteCapabilities が tx を開き、work 内で使う WriteCapabilities をその tx に束縛する。usecase の作者は tx を意識しなくていい。
// presentation/works/detail/post/index.ts
case 'setGenres': {
const data = await authorizeWrite(getAuthorizationDeps(), scope, 'manageWork', (caps) =>
// caps は tx に束縛された WriteCapabilities
// setWorkGenres と touch が 1 トランザクションで実行される
setWorkGenres({ itemId, genreIds: cmd.genreIds }, { genres: caps.genres, works: caps.works })
)
return c.json({ data })
}
authorizeWrite → runWithWriteCapabilities → db.transaction という経路が型で強制されているため、write usecase は必ず tx 内で走るという保証が型に現れる。
ロック戦略
判断基準
競合が起きてもビジネス上問題ない → 楽観的ロック(最終書き込みが正)
競合が起きると計算が壊れる → 悲観的ロック(SELECT FOR UPDATE)
作品タイトルの変更やライナーノーツの保存は「最後の状態が正」という業務ルールが成立するため楽観的ロックで十分だ。スタッフのお気に入り追加はすでに onConflictDoNothing で冪等になっている。
楽観的ロック(version による stale retry 検出)
// workRepository.saveTitle
const result = await db
.update(item)
.set({ updatedBy, version: sql`version + 1` })
.where(and(
eq(item.id, itemId),
eq(item.labelId, labelId),
eq(item.version, expectedVersion) // 一致しなければ 0 行更新
))
.returning({ id: item.id })
if (result.length === 0) throw createConflictError() // 409
悲観的ロック(将来の厳密な計算用)
音源ストレージ使用量の上限チェックや配信ロイヤリティのカウンタのような「読んで計算して書く」系には SELECT FOR UPDATE が必要になる。runWithWriteCapabilities の tx 内で自然に使える。
export const reserveStorageQuota = defineUsecase(async (input, caps) => {
await caps.quota.lockForUpdate({ labelId }) // SELECT FOR UPDATE
const current = await caps.quota.findCurrent({ labelId })
if (current.usedKB + input.sizeKB > current.limitKB) throw createQuotaExceededError()
await caps.quota.increment({ labelId, sizeKB: input.sizeKB })
})
冪等性:set-based 設計
なぜ set-based か
action: 'toggleFavorite' に value: boolean を持たせているのは意図的な設計だ。
// ❌ 操作を実行せよ → リトライで 2 回実行される
{ action: 'addFavorite' }
// ✅ この状態にせよ → リトライしても結果は同じ
{ action: 'toggleFavorite', value: true }
「状態を指定する」set-based な API はリトライ安全になる。同じ発想で setGenres は「この作品のジャンルを現在のこの集合に置き換えよ」という全置換で定義する。
stale retry への対応
set-based であっても「古い値でのリトライが新しい変更を上書きする」問題は残る。
1. スタッフが作品タイトルを "A" → "B" に変更
2. ネットワーク障害でタイムアウト(API は "B" を保存済み)
3. スタッフが "B" → "C" に変更
4. BFF がリトライで "B" を再送 → "C" が "B" に巻き戻る
これを防ぐのが楽観的ロックの version だ。クライアントが現在の version を一緒に送ることで、stale なリトライは 409 Conflict で弾かれる。
スケール戦略
BFF も API サーバーもステートレスなので水平スケールがそのまま効く。
① まずサーバー増強
BFF / API はステートレス → インスタンスを増やすだけ
② DB ボトルネック時
read replica で読み書き分離
③ 特定クエリ問題時
findDetail → findSummary 追加(作品一覧は軽量 projection)
インデックス最適化・N+1 は repository 内で JOIN に変更
(変更箇所が repository に閉じるので上位層は無影響)
音源・MV のファイル本体は DB ではなくオブジェクトストレージ(IMediaStoragePort)に置き、DB はメタデータと参照だけを持つ。重いバイナリと軽いメタデータでスケール特性を分けられる。
モノレポ運用で気になる観点の整理(パフォーマンスほか)
現状はモノレポ × モノリスで、BFF も Hono API も同一プロセス内の関数呼び出しで繋がっている。なぜこの構成から始めるかの判断軸は モノレポ × モノリスという選択 と 個人プロダクトのモノレポプロジェクト全体像について で扱った。ここでは「移行で何が変わるか」ではなく、いまモノレポを運用するうえで気をつける観点を整理する。
パフォーマンス:ボトルネックはネットワークではなく DB と取得量
同一プロセスの関数呼び出しなので、BFF → API 間にネットワークレイテンシは存在しない。したがって遅さの原因は通信ではなく、ほぼ次の 2 つに絞られる。
- DB アクセスの直列化 — 1 リクエストで複数の usecase を順番に
awaitしていると、レイテンシが素直に積み上がる。同一プロセスでも、独立した取得はPromise.allで並列化しておく。これは性能だけでなく、将来 HTTP 境界を挟んだときに「直列のままだとレイテンシが顕在化する」のを先に潰しておく意味もある。 - 集約の過剰取得 — 作品一覧で
findDetail(歌詞・クレジットまで含むフル)を回すと N 件ぶんの過剰取得になる。一覧はfindSummary、詳細はfindDetailで分ける(前述のクエリ設計)。N+1 は repository 内で JOIN に寄せ、上位層を無影響に保つ。
| 観点 | いまやっておくこと | 効く理由 |
|---|---|---|
| BFF の複数呼び出し | Promise.all で並列化 |
直列レイテンシの抑制。将来の HTTP 化でも差し替え不要 |
| 一覧の取得 | findSummary を使う |
フル取得の N 倍コストを回避 |
| N+1 | repository 内で JOIN | 変更が repository に閉じ、上位層は無影響 |
| 重いバイナリ | 音源・MV はストレージ、DB はメタデータのみ | DB を軽く保ち、スケール特性を分離 |
境界:モノレポは「import できてしまう」リスクと表裏
モノレポの一番の落とし穴は、物理的に同じリポジトリにあるせいで domain から infrastructure を、presentation から DB クライアントをうっかり import できてしまうことだ。依存方向(内向き)はコンパイラだけでは守られない。
- 依存方向を lint(ESLint の import 境界ルールや package 境界)で機械的に固定し、レイヤ越境を CI で落とす。
- domain はフレームワーク・DB 非依存の純粋関数に保つ。これは 土台と上物を分ける で書いた「交換可能性を前提にした設計」と同じ規律だ。
ビルド・CI:影響範囲だけを回す
モノレポは全体をビルド・型チェックすると遅くなる。変更されたパッケージとその依存先だけを再ビルド・再テストする(Turborepo 等のキャッシュ)構成にしておくと、作品 1 つのスキーマ追加でリポジトリ全体を回す事態を避けられる。
将来の物理分離は「差分ゼロ」で迎えられる
レイヤの責務がすでに分離されているため、Hono API を物理分離する日が来ても変わるのは BFF → API の通信が関数呼び出しから HTTP(1〜5ms)になる点だけだ。上で挙げた「呼び出しの並列化」「フル取得して BFF で UI 加工する」を今のうちに徹底しておけば、API / Domain / Repository には手を入れず、HTTP 境界を挟むだけで分離が完了する。つまりいまのモノレポ運用の規律が、そのまま将来の分離コストを下げている。
まとめ
この設計の核心を一言で言うと、「設計違反が構造的に起こり得ない状態を作る」 だ。
- 認可バイパス →
WriteCapabilitiesがauthorizeWrite経由でしか手に入らないので型エラー - labelId スコープ漏れ → scoped repo がクロージャに閉じ込めるので by-construction
- tx 境界の抜け →
runWithWriteCapabilitiesが強制するので漏れない - 余剰権限の使用 →
Exact<Caps, C>が各 usecase の最小権限を型強制
新しい usecase(作品の操作)を足すときの手順はシンプルだ。
1. capabilities.ts の最小スライスを型として定義
2. defineUsecase で実装(caps は Exact で縛られる)
3. presentation の switch に 1 ケース追加
4. 権限名を宣言して authorizeWrite に渡す
クエリを新しく書く必要はほぼなく、既存の findRef / findDetail / save / delete の組み合わせで大半が完結する。「最小のクエリを再利用してユースケースで作品の状態を構成する」という方針が、アーティストや作品が増えてコードベースが育っても複雑さが増殖しない理由だ。
本記事は、Composition Root・クロージャによる Entity・DDD のドメインモデル・フレームワーク非依存のレイヤー構造・エラーハンドリングの責務分離・テーブルの責務分離 といった個別の設計をアーティスト作品管理サービスというひとつの実装に統合し、認可・レーベルスコープ・トランザクション・ロック・冪等性という各関心ごとの制御がどこで効くかの全体像を描いたものだ。各パターンの背景はそれぞれの記事を参照してほしい。