はじめに
「DDD とか TDD とかスキーマ駆動開発とか、言葉だけグルグル回っていて、
実際どんな世界が見えるのかはイマイチ掴めていない…」
こう感じている方は多いのではないでしょうか。
本記事では、DDD・TDD・スキーマ駆動開発をセットで扱ったときに、開発現場で何が嬉しいのか を、まずは「全体像レベル」でまとめます。
細かいクラス設計や、完璧な流儀の話ではなく
- 「3つを組み合わせると、日々の開発体験がどう変わるのか」
- 「どんな線でつなぐと気持ちよく設計できるのか」
といった 世界観と流れ にフォーカスします。
この記事のゴール
この記事を読み終えると、読者の方は次のような状態になることを目指します。
- DDD / TDD / スキーマ駆動開発が、バラバラのキーワードではなく「1本の流れ」としてイメージできる
- 「仕様 → スキーマ → モデル → ユースケース → テスト」という線が、なぜ気持ちよいのか を言語化できる
- 「いきなり全部は無理でも、ここから取り入れてみよう」という 最初の一歩 を決められる
※コード例は TypeScript で書いていますが、言いたいことは言語に依存しないようにしています。
こういうモヤモヤ、ありませんか?
Webアプリ・業務アプリを作っていて、例えばこんな状況に覚えがないでしょうか。
- API仕様が決まらないので、フロントエンドが待ちぼうけ
- バックエンドの人と話すたびに仕様が変わる
- 画面・API・DBで、同じビジネスルールがコピペ状態
- テストは「とりあえずE2Eだけ」なので、リファクタが怖い
- ディレクトリ構成は “なんとなく層っぽい” けど、結局どこに何を書けばいいか分からない
一言でいうと、
仕様・モデル・テスト・API がバラバラの方向を向いている
状態です。
ここに DDD × TDD × スキーマ駆動開発 をバラバラではなく 「セットで」 入れると、見える世界がかなり変わります。
まずは3つのキーワードを、ざっくり1行ずつ
DDD(ドメイン駆動設計)
ビジネスの「言葉」と「ルール」をコードの構造(クラスや関数、モジュール境界)にちゃんと写す。
- ユーザーや企画者が使う言葉を ユビキタス言語 として定義する
-
Order,Reservation,DateRangeなど、「意味のある型」を作る - 「どの層にどんなロジックを書くか」をはっきり決める
TDD(テスト駆動開発)
「こう動いてほしい」をテストで先に書き、それを満たすようにコードを育てていく開発スタイル。
- 仕様の穴や矛盾に 実装前に 気づける
- テストが「将来の自分・チームへのドキュメント」になる
- リファクタしても、テストが通っていればひとまず安心できる
スキーマ駆動開発(OpenAPI など)
API やデータ構造の “設計図(スキーマ)” を先に書き、それを元にクライアント・サーバー・モックを機械的に作っていくスタイル。
- OpenAPI / JSON Schema / GraphQL SDL などを使う
- スキーマから型定義・クライアントコードを自動生成する
- モックサーバーもスキーマから起こせば、バックエンド未実装でもフロントが進められる
よくある失敗パターン:バラバラに導入してしまう
正直なところ、これらを 単体で つまみ食いすると、次のような状態になりがちです。
- 「DDDっぽい層の名前」だけ増えたコードベース
- テストはあるけど、ユースケースレベルの意図は曖昧
- OpenAPIはあるけど、誰も見ていない “飾りのドキュメント”
つまり、
キーワードは導入したのに、日々の開発体験が大して変わっていない
という状態です。
これ、わりと “DDD・TDD・スキーマ駆動あるある” ではないでしょうか。
3つをセットで考えると「線」が1本通る
ここからが本題です。
抽象的なつながり方
3つをそれぞれバラバラではなく、「流れ」としてつなげると、こんなイメージになります。
- スキーマ駆動開発 で「外との契約」を先に決める
- DDD で「中のルールとモデル」を整理する
- TDD でその接続と振る舞いをテストで固定する
この3つを 順番のある流れ としてつなぐと、
仕様 → スキーマ → モデル → ユースケース → テスト
という 1本の線 が通ります。
一言でいうと
「どこに何を書くか」が決まり、仕様変更にビクビクしない設計が手に入る。
この線が通ると、
「この話はどこに書くべきか?」という迷いが減り、
レビューやリファクタのコストも下がっていきます。
ちょっとだけ具体化してみる(予約機能の例)
例えば「何かの予約機能」を作るとします。
1. 会話から「言葉」を拾う(DDDの入口)
ビジネス側との会話の中で、こんな単語が出てきたとします。
- 予約(Reservation)
- 枠(Slot)
- 利用者(User)
- 利用可能期間(AvailableRange)
ここでやるのは、次のような 言葉の整理 です。
- 「予約 と 枠 は違う概念ですよね?」
- 「キャンセルされた予約はどう扱うべきですか?」
- 「1つの枠に複数人予約できますか?」
この整理結果が、そのまま Reservation, Slot, User, AvailableRange
といった モデルの候補 になります。
2. 「外との契約」をスキーマで固める(スキーマ駆動)
次に、その言葉を使って APIのスキーマ(OpenAPIなど) を書きます。
ざっくりイメージだけ書くと:
paths:
/reservations:
post:
summary: 予約を作成する
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateReservationRequest'
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/ReservationResource'
ここで重要なのは次の2点です。
- エンドポイントやフィールド名に、さっき整理した ユビキタス言語 をそのまま使うこと
- 「API のための言葉」を新たにでっち上げないこと
これにより、外部インターフェース(契約) がひとまず固まります。
3. スキーマから型やクライアントを機械的に生やす
OpenAPI が書けたら、そこから TypeScript の型やクライアントを生成します。
// 例: OpenAPI Generator が吐いた型
export interface ReservationResource {
id: string;
slotId: string;
userId: string;
status: "PENDING" | "CONFIRMED" | "CANCELLED";
reservedAt: string; // ISO文字列
}
ここでのポイントは、
- フロントエンド側が 「何が返ってくるか」 を型として持てる
- APIクライアントを手書きしなくてよいので、バグの温床が1つ消える
という点です。
4. 「中身のルール」は Domain モデルとして表現する(DDD)
一方で、アプリ内部では Reservation や ReservationStatus などのドメインモデル を定義します。
// 値オブジェクト的な Status
export class ReservationStatus {
private constructor(
private readonly value: "PENDING" | "CONFIRMED" | "CANCELLED",
) {}
static pending() {
return new ReservationStatus("PENDING");
}
static confirmed() {
return new ReservationStatus("CONFIRMED");
}
static cancelled() {
return new ReservationStatus("CANCELLED");
}
canCancel(): boolean {
return this.value === "CONFIRMED" || this.value === "PENDING";
}
toString() {
return this.value;
}
}
// エンティティ
export class Reservation {
constructor(
public readonly id: string,
public readonly slotId: string,
public readonly userId: string,
private status: ReservationStatus,
) {}
cancel() {
if (!this.status.canCancel()) {
throw new Error("キャンセルできない状態です");
}
this.status = ReservationStatus.cancelled();
}
}
ここでのイメージは、
- API の
ReservationResourceは 「外の顔」 -
ReservationやReservationStatusは 「内臓」
という構図です。
両者は Mapper でつなぎます。
// API → Domain
function toDomain(resource: ReservationResource): Reservation {
return new Reservation(
resource.id,
resource.slotId,
resource.userId,
new ReservationStatus(resource.status),
);
}
5. ユースケースを TDD で「固定」する
最後に、ユースケース(アプリケーションサービス)を TDD で書きます。
describe("予約キャンセルユースケース", () => {
it("確定済みの予約はキャンセルできる", async () => {
// Arrange
const repo = new InMemoryReservationRepository(/* ... */);
const usecase = new CancelReservationUseCase(repo);
// Act
await usecase.execute({ reservationId: "resv-1" });
// Assert
const updated = await repo.findById("resv-1");
expect(updated.status.toString()).toBe("CANCELLED");
});
it("すでにキャンセル済みの予約はエラーになる", async () => {
// Arrange 〜 略 〜
// Assert
await expect(
usecase.execute({ reservationId: "resv-2" }),
).rejects.toThrow("キャンセルできない状態です");
});
});
ポイントは、
-
ユースケース名 = テストの
describe名 になることが多い - ここに書いていることはそのまま
「ビジネスサイドに見せても通じる仕様書」になり得る
という点です。
組み合わせることで得られる3つのメリット
ここまでの流れを、「何が嬉しいか」という視点で改めて整理します。
1. 仕様の一貫性が保てる
- ユビキタス言語
→ スキーマ(OpenAPIなど)
→ ドメインモデル
→ ユースケーステスト
が 同じ言葉 を共有します。
その結果、
- 「画面とAPIの言葉が違う」
- 「APIの
statusはapprovedなのに、画面ではconfirmedと表示している」
といった ちぐはぐ が起きにくくなります。
2. 変更に強くなる
- APIの変更 → スキーマ変更
→ 型が崩れるところがコンパイルエラーで見える - ビジネスルールの変更 → ドメインモデルの振る舞い変更
→ ユースケーステストが教えてくれる
という形で、どこを直せばいいか」が 機械的に分かるようになります。
3. チームでの会話が楽になる
- DDD のユビキタス言語があるおかげで「あのテーブル」「あのフラグ」みたいな曖昧な会話が減る
- スキーマ駆動のおかげで「このAPI、何返してたっけ?」が 常に1ファイルを見れば分かる
- TDD のおかげで、「ここを変えたら他が壊れそう」がテストで検知できる
結果として、設計レビューや仕様検討の場での議論がコードと地続き になります。
「全部を一度にやらない」のも大事
ここまで読むと、
「3つ全部を完璧にやらないといけないのか…?」
と感じるかもしれませんが、
実務では段階的に取り入れていくのが現実的です。
例えば、次のようなステップでも十分です。
- まず API スキーマをちゃんと書いて、そこから型とクライアントだけでも生成してみる
- 次に、ビジネスロジックが複雑な部分だけでも、DDD 的にモデルとユースケースを切り分けてみる
- 最後に、そのユースケース単位で TDD を始めてみる
いきなり「全部のコードをDDD化+TDD化+スキーマ駆動化」しようとすると、
ほぼ確実に挫折するので、スコープを絞って試す のがポイントです。
おわりに(まとめ)
この記事で一番伝えたかったのは、ざっくり言うとひとつだけです。
DDD × TDD × スキーマ駆動開発を
バラバラではなく “ひとつの流れ” として扱うと、
開発体験も設計の質もかなり変わる。
設計のキーワードだけ知っている状態から
「実際どういう世界を目指しているのか」が
ほんの少しでもイメージできていれば嬉しいです。
「ここをもっと具体的に知りたい」「この辺の話を掘ってほしい」などあれば、
コメントなどでフィードバックをいただけると助かります。
どの部分を深堀りすると実務で使いやすいか、
今後の記事のテーマ選定の参考にさせていただきます。