ドメインモデリングが崩れると構造全体が崩れる — DDD における設計の土台
Published on 2026年6月28日
設計はじめに
クリーンアーキテクチャや capability token による認可設計をどれだけ丁寧に作っても、ドメインモデリングがずれていると構造全体が崩れる。
前回の 設計違反が型エラーになるバックエンド では、音楽レーベル/アーティストの作品(音源・MV・ジャケット・ライナーノーツ)を管理するサービスを題材に、認可・スコープ・トランザクション境界を型で封じる仕組みを書いた。だがあの仕組みが効くのは、その下にある Work(作品)集約のモデリングが正確だからだ。本記事では同じ題材で、その土台側を掘る。
「DB のスキーマをそのままドメインに持ち込む」「エンティティと値オブジェクトの区別が曖昧」「集約の境界が引けていない」——これらはコードの問題ではなくモデリングの問題だ。コードを書き直しても根本は変わらない。
TypeScript + Hono + Drizzle の構成を例に、なぜモデリングが先で実装が後なのか、そしてモデリングが正確であることで何が得られるかを整理する。
モデリングがずれると何が起きるか
典型的な失敗パターンがある。
DB スキーマを先に作る
↓
スキーマに合わせてモデルを作る
↓
モデルが DB の都合を反映している
↓
Usecase に if 文と変換処理が溢れる
↓
権限の境界が引けない
↓
全体がスパゲッティになる
正確なモデルと崩れたモデルで Usecase の見え方が変わる。
// ❌ DB の都合がドメインに漏れている
const usecase = async (itemId: string) => {
const item = await itemRepository.findById(itemId)
// item.type を見て分岐するコードが至る所に現れる
if (item.type === 'note') {
const note = await noteRepository.findById(itemId)
return buildNotesDetail(item, note)
}
if (item.type === 'media') {
const media = await mediaRepository.findById(itemId)
return buildMediaDetail(item, media)
}
}
// ✅ ドメインモデルが正確
const usecase = async (itemId: string) => {
const work = await workRepository.findDetail({ itemId })
assertWorkExists(work)
// 分岐は集約のメソッドに閉じている
if (!work.isNote()) throw createWorkNotFoundError()
return buildNotesDetail(work)
}
DB の都合(item テーブルが note と media 双方の親レコードを持つ多態集約点)をドメインに持ち込むと、業務ロジックが item.type を見て分岐するコードで溢れる。モデリングが正確であれば Usecase は「組み合わせるだけ」になる。
モデリングの順序
① 何がエンティティか
② 何がバリューオブジェクトか
③ 集約の境界はどこか
④ プロダクト間でどの概念を共有するか
↓ これが決まって初めて
⑤ 権限の整理(誰が何を読めるか・書けるか)
↓ これが決まって初めて
⑥ Usecase の実装(組み合わせるだけ)
この順序が崩れると後から全部やり直しになる。
エンティティとバリューオブジェクト
エンティティ:同一性で識別するもの
// Work はエンティティ
// id が同じであれば「同じ作品」
export type WorkState = {
readonly id: string // 同一性の根拠
readonly title: WorkTitle // VO
readonly kind: WorkKind // VO
readonly payload: WorkPayload
}
タイトルが変わっても、ジャンルが変わっても、id が同じであれば同じ Work だ。
バリューオブジェクト:値そのものが同一性
// WorkTitle は VO
// "夜明けのワルツ" という値そのものが同一性
// id を持たず、値が同じであれば交換可能
export interface WorkTitle {
readonly value: string
}
export const createWorkTitle = (value: string): WorkTitle => {
const trimmed = value.trim()
if (trimmed.length === 0) throw createInvalidTitleError('title is required')
if (trimmed.length > 255) throw createInvalidTitleError('title too long')
return { value: trimmed }
}
VO の重要な役割は値の検証を一箇所に集めることだ。WorkTitle が存在する限り、「空文字のタイトルが存在する」「255文字を超えるタイトルが保存される」という状態が構造的に起きない。Usecase も Repository もバリデーションを知らなくていい。
判断基準
「同一性で追跡するか」→ エンティティ
「値そのものが意味を持つか」→ バリューオブジェクト
Work → エンティティ(id で追跡)
Genre → エンティティ(id で追跡)
Title → VO("夜明けのワルツ" という値が意味を持つ)
Kind → VO('track' / 'note' という値が意味を持つ)
集約の境界
集約は「一緒に変更されるべきもの」をまとめた単位だ。
Work を集約ルートにする理由
export type WorkState = {
readonly id: string
readonly labelContext: LabelContext // レーベルスコープのメタ
readonly kind: WorkKind
readonly title: WorkTitle
readonly genres: GenreRef[] // ジャンルへの参照(集約をまたぐ)
readonly credits: ArtistCreditRef[] // 参加アーティストへの参照(集約をまたぐ)
readonly payload: WorkPayload // 種別固有の中身
}
genres は GenreRef(参照)であって Genre エンティティそのものではない。Genre は独立した集約であり、Work は Genre の id と表示名だけを参照する。credits(参加アーティスト)も同様に参照だ。
集約の境界が曖昧だと何が起きるか
// ❌ 集約の境界が曖昧
// Work が Genre の内部状態を変えようとしている
work.genres[0].color = '#ff0000' // Genre は Work の集約の外
// ✅ 集約の境界が明確
// Genre を変えたければ Genre の集約を通す
await genreRepository.updateColor({ genreId, color: '#ff0000' })
// Work はジャンルへの参照を持つだけ
集約の境界を守ることで「どの操作がどのトランザクション境界に属するか」が自然に決まる。
payload を判別可能ユニオンにする
同じ Work 集約でも種別によって中身がまったく異なる。音源や MV はエンコード処理や歌詞を持つが、ライナーノーツはテキスト本文しか持たない。これを継承で表現しようとすると「NoteWork と MediaWork の共通の親クラス」が生まれ、共通化できない部分が null だらけになる。
// ❌ 継承で表現しようとした場合
class BaseWork {
body?: string // ライナーノーツ系のみ
extension?: string // メディア系のみ
lyrics?: Lines // 音源・MV のみ
streamingUrl?: string // 配信リンク系のみ
// null だらけになる
}
// ✅ 判別可能ユニオン
export type LyricLine = { atMs: number; text: string }
export type NotePayload = {
type: 'note'
body: string // ライナーノーツ / クレジット本文
}
export type MediaPayload = {
type: 'media'
extension: string | null
processingStatus: 'processing' | 'completed' | null
lyrics: { lines: LyricLine[] } | null
streaming: { url: string; description: string } | null
}
export type WorkPayload = NotePayload | MediaPayload
TypeScript の型システムが種別ごとの中身を保証してくれる。payload.type === 'note' で narrowing した後は payload.body に安全にアクセスできる。
DB の都合とドメインの概念を切り離す
これが実務で最も重要な判断だ。
DB のテーブル設計とドメインモデルは一致しなくていい
DB は永続化の都合でテーブルを設計する。ドメインは業務の言葉で概念を定義する。この2つが一致する必要はない。テーブルが自分の責務だけを持つべきという話は テーブルは自分のことだけを知っていればいい で書いた。
DB のテーブル設計 ドメインモデル
────────────────────────── ──────────────────────────
item(多態集約点) Work(集約ルート)
note(item のサブタイプ) NotePayload(判別可能ユニオン)
media(item のサブタイプ) MediaPayload(判別可能ユニオン)
work_genre(中間テーブル) genres: GenreRef[](参照のリスト)
item テーブルは DB の正規化の都合で存在する。しかしドメインに Item という概念は不要だ。業務として意味があるのは Work(作品)であり、item.type による分岐は永続化の詳細に過ぎない。
mapper で切り離す
// infrastructure/repositories/workRepository/index.ts
async findDetail({ itemId }): Promise<Work | null> {
const row = await db.query.item.findFirst({
where: (i, { and, eq }) => and(eq(i.id, itemId), eq(i.labelId, labelId)),
with: { note: true, media: true, workGenres: { with: { genre: true } } },
})
if (!row) return null
// item.type の分岐はここだけ。ドメインには出ない。
if (row.type === 'note' && row.note) {
return reconstructWork({
id: row.id,
kind: 'note',
payload: { type: 'note', body: row.note.body ?? '' },
genres: row.workGenres.map(g => toGenreRef(g.genre)),
// ...
})
}
if (row.type === 'media' && row.media) {
return reconstructWork({
id: row.id,
kind: row.media.kind, // 'track' | 'musicVideo' | 'artwork' | 'streamingLink'
payload: { type: 'media', extension: row.media.extension, ... },
genres: row.workGenres.map(g => toGenreRef(g.genre)),
// ...
})
}
return null
}
item.type の分岐が workRepository の mapper に閉じ込められている。ドメイン層も Usecase 層も item テーブルの存在を知らない。
モデリングと権限整理の関係
集約の境界が決まると権限の境界が自然に決まる。
// Work の集約境界が決まると
// 「Work に対して何ができるか」が明確になる
export type WriteCapabilities = {
actor: AuthorizedActor
works: IWorkRepository // Work の境界内の操作
genres: IGenreRepository // Genre の境界内の操作
favorites: IFavoriteRepository
}
// Usecase は「最小の権限スライス」を要求するだけ
export type ChangeWorkTitleCapabilities = {
works: IWorkRepository // title 変更は Work の境界内
}
export type SetWorkGenresCapabilities = {
works: IWorkRepository // touch(Work の境界内)
genres: IGenreRepository // work_genre 置き換え(Genre の境界内)
}
集約の境界がずれていると「この操作の権限はどこに置くべきか」が曖昧になる。モデリングが正確であれば権限の整理は自然に決まる。capability token として型で封じる具体的な仕組みは 前回の記事 で詳述した。
プロダクト横断でのドメイン知識共有
複数プロダクトを展開するとき、共通のドメイン概念を正確に定義することがエコシステムの土台になる。
packages/domain-core/
├── events/
│ ├── WorkReleased.ts ← イベントの型定義
│ ├── WorkPlayed.ts
│ └── GenreCreated.ts
├── refs/
│ ├── ArtistRef.ts ← 横断的な参照型
│ ├── GenreRef.ts
│ └── WorkRef.ts
└── shared/
└── LabelContext.ts ← 共通のレーベルスコープ
イベントの型定義が packages/domain-core に一箇所あることで、studio アプリが WorkReleased を発行し discovery アプリが受け取るときの型が一致することを保証できる。
// apps/studio/usecases/releaseWork.ts
await caps.events.publish({
type: 'WorkReleased', // domain-core の型
workId: work.getId(),
artistId: work.getPrimaryArtistId(),
})
// apps/discovery/handlers/onWorkReleased.ts
onEvent('WorkReleased', async (event) => {
// 同じ型定義から来るので型安全
await catalogRepository.addEntry({ sourceId: event.workId })
})
ここでもモデリングが先だ。「WorkReleased というイベントに何が含まれるべきか」はドメイン知識の問題であり、実装の問題ではない。
サブプロダクト統合とクリーンアーキテクチャの耐久性
モデリングが正確であれば、プロダクトが増えても構造が崩れない。その理由を整理する。
プロダクトの分割基準
同一プロダクト内 別プロダクトとして切り出す
──────────────────────── ────────────────────────
同じ課題を解くための機能 解決したい課題が違う
同じユーザーが使う ユーザーが違う
データが密結合 ビジネスの文脈が違う
「作品管理」と「ジャンル管理」は同じ課題空間にいるのでプロダクト内でモジュール分割するだけでいい。一方「作品のリリース(レーベル/アーティスト向け)」と「作品の発見・再生(リスナー向け)」は解決したい課題もユーザーの関心も違うため、別プロダクトとして切り出す判断が自然になる。
重要なのは技術的な都合ではなくビジネスの文脈で分割を判断することだ。「コードが大きくなったから分ける」ではなく「課題が違うから分ける」という順序になる。この判断軸は モノレポ × モノリスという選択 でも扱った。
エコシステムとしての統合
レーベル/アーティストが作品を登録するアプリ・リスナーが作品を聴くアプリ・再生や反応を分析するアプリがあるとき、それぞれ提供対象は異なるが一連のエコシステムとして繋がっている。
アーティスト・レーベル → studio で作品をリリースする
↓ WorkReleased イベント
リスナー → discovery で作品を聴く
↓ WorkPlayed / Engaged イベント
レーベル・マーケ → insights で再生・反応を分析する
モノレポとして管理することでこの構造が成立する。
monorepo/
├── packages/
│ ├── server-core/ ← 認証・認可基盤・DB接続(全アプリ共通)
│ ├── domain-core/ ← イベント型定義・共通参照型
│ └── ui/ ← 共通コンポーネント
│
└── apps/
├── studio/ ← 作品の登録・管理(レーベル/アーティスト向け)
├── discovery/ ← 作品の発見・再生(リスナー向け)
└── insights/ ← 分析(レーベル/マーケ向け)
server-core が共通基盤になる
認証・認可・DB接続・エラーハンドリングは全アプリ共通で持つべきものだ。
packages/server-core/
├── auth/ ← 認証(better-auth 等)
├── authorization/ ← capability token の仕組み
│ ├── LabelMember
│ └── AuthorizedActor
├── database/ ← DB 接続(Drizzle / pg)
├── container/ ← Composition Root の基盤
└── errorMap/ ← エラーハンドリング基盤
各アプリは server-core を使うことで認証・認可・DB接続が最初から整っている。Composition Root に基盤を集約する考え方は 依存関係の集約 のとおりだ。各アプリが追加するのはドメイン固有の部分だけになる。
各アプリが持つもの
├── domain/ ← そのアプリ固有の集約・VO
├── usecases/ ← そのアプリ固有のユースケース
├── presentation/ ← そのアプリ固有のエンドポイント
└── infrastructure/
├── container/ ← server-core の基盤を使い依存を配線
└── repositories/ ← そのアプリ固有のクエリ
権限がアプリ文脈ごとに閉じ込められる
server-core が capability token の仕組みを提供し、各アプリが「自分の文脈で何を読めるか・書けるか」を型として定義する。
// apps/studio/usecases/authorization/capabilities.ts
export type WriteCapabilities = {
actor: AuthorizedActor // server-core の型
works: IWorkRepository // studio 固有
genres: IGenreRepository // studio 固有
}
// apps/discovery/usecases/authorization/capabilities.ts
export type WriteCapabilities = {
actor: AuthorizedActor // server-core の型(共通)
playlists: IPlaylistRepository // discovery 固有
favorites: IFavoriteRepository // discovery 固有
}
studio アプリの WriteCapabilities には playlists の repository が存在しない。型として存在しないので studio の usecase がリスナーのプレイリストを直接書こうとするとコンパイルエラーになる。アプリ間の越境アクセスが型で封じられる。
studio usecase が discovery を直接触ろうとする
↓
caps.playlists が型に存在しない
↓
コンパイルエラー
↓
イベント経由で連携する設計に自然に誘導される
「文脈をまたぐならイベント経由」というアーキテクチャの原則が、型制約として設計に内包されている状態だ。
プロダクト間はイベントで繋ぐ
各アプリは「自分のドメインで何が起きたか」という事実だけを伝える。相手のアプリが何をするかを知らない。
// apps/studio/usecases/releaseWork.ts
await caps.works.release({ itemId })
// 「リリースした」という事実だけを伝える
await caps.events.publish({
type: 'WorkReleased', // domain-core の型定義
workId: work.getId(),
artistId: work.getPrimaryArtistId(),
releasedAt: new Date(),
})
// studio はここまで。discovery や insights が何をするかを知らない
// apps/discovery/handlers/onWorkReleased.ts
onEvent('WorkReleased', async (event) => {
await catalogRepository.addEntry({
sourceId: event.workId,
type: 'work',
releasedAt: event.releasedAt,
})
})
// apps/insights/handlers/onWorkReleased.ts
onEvent('WorkReleased', async (event) => {
await analyticsRepository.record({
event: 'work_released',
artistId: event.artistId,
})
})
studio に手を加えることなく insights アプリを追加できる。イベントの型が domain-core で一元管理されているので型安全が保たれる。
拡張しても触る場所が明確
どれだけ統合が複雑になっても構造が崩れない理由は、拡張の種類ごとに触る場所が決まっているからだ。
拡張の種類 触る場所
────────────────────────── ──────────────────────────
新しい usecase apps/xxx/usecases に追加
新しいアプリ apps/xxx を追加
プロダクト間の連携 domain-core にイベント型追加
共通認証・認可の変更 server-core のみ
どのケースも「追加するだけ」で既存が壊れない。これが成立するのはレイヤーが関心事に閉じていて、依存が一方向に整理されているからだ。
まとめ
モデリングが土台である理由を整理する。
モデリングが正確 モデリングがずれている
──────────────────────── ────────────────────────
Usecase が組み合わせるだけ Usecase に変換処理が溢れる
権限の境界が自然に決まる 権限がどこに置くか曖昧
DB の都合を閉じ込められる DB の都合がドメインに漏れる
プロダクト横断で型安全 イベントの型が食い違う
アプリが増えても構造が保たれる 越境アクセスが静かに増殖する
クリーンアーキテクチャと DDD の組み合わせが耐えられる理由はここにある。
- レイヤーが関心事に閉じているので拡張は追加、変更は局所になる
- capability token が文脈ごとの権限を型で封じ込めるので越境アクセスがコンパイルエラーになる
- ドメインイベントが疎結合を担保するのでアプリが増えても既存が壊れない
server-coreが共通基盤を提供するので新しいアプリはドメイン固有の部分だけ書けばいい
モデリングに時間をかけることは実装を遅らせることではない。後から直す手戻りを最小にすることだ。「エンティティか VO か」「集約の境界はどこか」「DB の都合かドメインの概念か」「同一プロダクトか別プロダクトか」——この問いを問い続けることが、スケールしても崩れない設計の唯一の土台になる。
仕組みの側(認可・スコープ・トランザクションを型で封じる実装)は 設計違反が型エラーになるバックエンド に、その土台となるモデリングの考え方は クロージャによる Entity 設計 や オブジェクトとドメインモデルの違い にまとめている。本記事はその両者をつなぐ「なぜモデリングが先か」を整理したものだ。