「画面の修正を頼んだだけなのに、なぜこんなに時間がかかるのか」──システム開発の現場で、こうした疑問を感じたことのある経営者の方は少なくないのではないでしょうか。その原因の多くは、業務の構造とコードの構造がかみ合っていないことにあります。本記事では、この課題を解決するドメイン駆動設計(DDD)のフロントエンドへの応用について、具体的な手順とともにお伝えします。
フリーランスエンジニアの方にとっても、DDD の知見は案件の提案力や設計フェーズでの存在感を大きく高めてくれます。「画面を作れる人」から「業務を設計できる人」へステップアップするためのヒントとして、ぜひ最後までお読みいただければ幸いです。
ドメイン駆動設計(DDD)とは何か
ドメイン駆動設計(Domain-Driven Design、以下DDD)は、エリック・エヴァンスが2003年に提唱したソフトウェア設計手法です。一言でまとめると、「業務の言葉と構造をそのままコードに反映させる」という考え方になります。
従来の開発では、画面設計やデータベース設計を起点にシステムを構築することが一般的でした。しかしこのアプローチでは、業務ルールがコードのあちこちに散らばり、仕様変更のたびに広範囲の修正が必要になります。私自身、過去のプロジェクトで「受注」という概念が画面・API・データベースのそれぞれで異なる定義を持ってしまい、改修のたびに混乱が生じた経験があります。
DDDでは、まず業務の専門家と開発者が共通の言葉(ユビキタス言語)を定義し、その言葉をそのままコードのクラス名や関数名に使います。こうすることで、経営者が「この業務ルールを変えたい」と伝えたとき、開発者がコードのどこを修正すべきかすぐに特定できるようになります。
| 比較項目 | 従来の画面起点設計 | ドメイン駆動設計 |
|---|---|---|
| 設計の起点 | 画面レイアウト・DB構造 | 業務ルール・業務用語 |
| 仕様変更時の影響範囲 | 広範囲に波及しやすい | 該当ドメインに局所化 |
| 業務担当者との会話 | 技術用語への翻訳が必要 | 共通言語でそのまま会話 |
| 初期の設計コスト | 低い | やや高い |
| 長期的な保守コスト | 高くなりがち | 抑えやすい |
DDDの主要な構成要素
DDDには多くの概念がありますが、フロントエンドへの適用で特に重要なものを整理しておきます。
- エンティティ(Entity):一意のIDで識別されるオブジェクトです。たとえば「注文」は注文番号で区別され、状態が変わっても同一の注文として扱われます。
- 値オブジェクト(Value Object):IDを持たず、値そのもので等価性を判断するオブジェクトです。「住所」や「金額」がこれにあたります。東京都港区と東京都港区は、別々に作成しても同じ値として扱えます。
- 集約(Aggregate):関連するエンティティと値オブジェクトをまとめた単位です。「注文」集約は、注文エンティティ・注文明細・配送先住所などをひとまとまりとして管理します。
- ドメインサービス:特定のエンティティに属さない業務ロジックを担います。たとえば「2つの注文を統合する」という処理は、どちらの注文に属するか曖昧なため、ドメインサービスとして定義します。
- リポジトリ(Repository):データの永続化・取得を抽象化する層です。フロントエンドでは、APIとのやり取りをリポジトリとして切り出すことで、データ取得の詳細をドメイン層から隠蔽できます。
これらの概念をすべて一度に導入する必要はありません。まずはエンティティと値オブジェクトの区別を意識するだけでも、コードの見通しは大きく変わります。
なぜフロントエンドにDDDが必要なのか
「DDDはバックエンド向けの設計手法では?」と思われた方もいらっしゃるかもしれません。実際、DDDはサーバーサイドの文脈で語られることが多く、私自身も当初はフロントエンドへの適用は過剰だと考えていました。しかし、近年のフロントエンド開発は状況が大きく変わっています。
現代のフロントエンドは、単なる表示層ではありません。ReactやVue.jsなどのフレームワークの普及により、フロントエンド側でも複雑な状態管理や業務ロジックを扱うようになりました。たとえば、ECサイトの「カート」を考えてみてください。商品の追加・削除、数量変更、割引計算、在庫チェック──これらの処理がすべてフロントエンドのコンポーネントに直接書かれていると、改修時に思わぬ不具合が発生しやすくなります。
あるプロジェクトでは、割引ロジックがカート画面・商品一覧画面・注文確認画面の3箇所に重複して実装されていました。割引率を変更する際に1箇所を修正し忘れ、画面ごとに異なる金額が表示されるという問題が発生したのです。振り返ると、業務ロジックを画面単位ではなくドメイン単位で管理していれば防げた問題でした。
フロントエンドの複雑化がもたらす課題
ここ数年、フロントエンドが担う責務は加速度的に増えています。SPA(Single Page Application)の普及によりルーティングやセッション管理がフロントエンドに移り、さらにリアルタイム通信やオフライン対応といった要件も珍しくなくなりました。
具体的な例を挙げてみます。ある勤怠管理システムの案件では、以下のようなロジックがすべてフロントエンドに存在していました。
- 勤務時間の自動計算:休憩時間の控除、深夜帯の割増、法定休日の判定
- 申請ワークフロー:承認ルートの分岐、差し戻し時の状態遷移
- リアルタイムバリデーション:36協定に基づく残業時間上限のチェック
これらがcomponents/AttendanceForm.tsxという1つのコンポーネントに1,200行以上にわたって書かれていたのです。新しいメンバーがこのファイルを開いたとき、「どこからどこまでが表示ロジックで、どこからが業務ロジックなのか」を把握するのに丸1日かかったと聞きました。
フリーランスエンジニアとして既存プロジェクトに参画する場面では、このような「業務ロジックがUIに密結合した状態」に遭遇することが少なくありません。DDDの視点を持っていれば、参画初日から「まずドメインの概念を整理しましょう」と提案でき、チームに対する信頼を早い段階で築くことができます。
フロントエンドDDDの実践ステップ
では具体的に、フロントエンドにDDDを導入するにはどうすればよいのでしょうか。以下の4ステップで進めることをおすすめします。
ステップ1:ユビキタス言語の定義
最初に、業務で使う言葉を一覧化します。「注文」「受注」「オーダー」が混在していないか、「顧客」と「ユーザー」は同じ意味で使われているかを整理します。この作業は、経営者や業務担当者と開発者が一緒に行うことが重要です。
// ユビキタス言語の定義例(TypeScript型定義として表現)
type 注文ステータス = '下書き' | '確定' | '出荷済み' | 'キャンセル'
interface 注文 {
注文番号: string
顧客: 顧客
明細: 注文明細[]
ステータス: 注文ステータス
合計金額(): number
}実際のコードでは英語の命名規則を使いますが、設計段階では日本語でドメインモデルを定義すると、業務担当者との認識のずれを防ぎやすくなります。
ユビキタス言語を定義する際に私たちがよく使うのは、用語集スプレッドシートです。以下のような列を用意し、ステークホルダー全員でレビューします。
| 用語(日本語) | 用語(コード) | 定義 | 同義語・紛らわしい言葉 | 所属コンテキスト |
|---|---|---|---|---|
| 注文 | Order | 顧客が商品を購入する行為と、その記録 | 受注、オーダー、購入 | 販売 |
| カート | Cart | 注文確定前の商品選択状態 | 買い物かご、バスケット | 販売 |
| 顧客 | Customer | 会員登録済みで、1回以上の購入実績がある人 | ユーザー、会員 | 販売 |
| 配送先 | ShippingAddress | 商品を届ける物理的な住所 | 届け先、送り先 | 配送 |
この表があるだけで、コードレビュー時に「ここのuserはcustomerではないですか?」という指摘が自然に生まれるようになります。あるプロジェクトでは、この用語集をPRテンプレートに組み込み、ドメイン用語の一貫性をレビューの必須チェック項目にしていました。
ステップ2:ディレクトリ構成の見直し
多くのフロントエンドプロジェクトでは、components/、hooks/、utils/のように技術的な役割でフォルダを分けています。DDDの考え方を取り入れる場合は、業務ドメインごとにフォルダを切ります。
src/
├── domains/
│ ├── order/ # 注文ドメイン
│ │ ├── models/ # ドメインモデル(型定義・バリデーション)
│ │ ├── services/ # ドメインサービス(業務ロジック)
│ │ ├── components/ # 注文関連のUIコンポーネント
│ │ └── hooks/ # 注文関連のカスタムフック
│ ├── customer/ # 顧客ドメイン
│ └── product/ # 商品ドメイン
├── shared/ # ドメイン横断の共通処理
└── pages/ # ページ(各ドメインを組み合わせる層)この構成であれば、「注文に関する修正はorder/を見ればよい」と判断でき、影響範囲の見積もりが格段に容易になります。
実際のプロジェクトでもう少し踏み込んだ例をお見せします。ECサイトの注文ドメインを想定した、より詳細なディレクトリ構成です。
src/domains/order/
├── models/
│ ├── Order.ts # 注文エンティティ
│ ├── OrderItem.ts # 注文明細(値オブジェクト)
│ ├── OrderStatus.ts # 注文ステータス(値オブジェクト)
│ └── DiscountPolicy.ts # 割引ポリシー(値オブジェクト)
├── services/
│ ├── OrderCalculator.ts # 合計金額・割引計算
│ ├── OrderValidator.ts # 注文のビジネスルール検証
│ └── OrderFactory.ts # 注文の生成パターン
├── repositories/
│ └── OrderRepository.ts # API通信の抽象化
├── components/
│ ├── OrderSummary.tsx # 注文概要表示
│ ├── OrderItemList.tsx # 明細一覧
│ └── OrderStatusBadge.tsx # ステータスバッジ
└── hooks/
├── useOrder.ts # 注文の取得・操作
└── useOrderValidation.ts # リアルタイムバリデーションここで大切なのは、repositories/の存在です。APIとの通信処理をリポジトリとして切り出しておくと、バックエンドのAPI仕様が変わったときにもドメインロジックに影響を与えずに対応できます。フリーランスとして途中参画する際にも、「まずリポジトリを見ればデータの流れがわかる」という見通しの良さは大きな助けになります。
ステップ3:業務ロジックのドメイン層への集約
UIコンポーネントから業務ロジックを分離し、ドメイン層に集約します。同じような悩みを抱えている方も多いのではないでしょうか。コンポーネントが肥大化して見通しが悪くなる問題は、この分離によって大きく改善されます。
具体例でその効果を見てみましょう。割引計算のロジックがコンポーネントに埋め込まれているケースを、ドメイン層に切り出す前と後で比較します。
Before:コンポーネントに業務ロジックが混在
// components/CartSummary.tsx
const CartSummary = ({ items, coupon }: Props) => {
// 小計の計算
const subtotal = items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
)
// 割引計算(業務ロジックがUIに埋まっている)
let discount = 0
if (coupon?.type === 'percentage') {
discount = subtotal * (coupon.value / 100)
} else if (coupon?.type === 'fixed') {
discount = Math.min(coupon.value, subtotal)
}
// 会員ランク割引(さらに条件が増える)
if (memberRank === 'gold') {
discount += subtotal * 0.05
}
const total = subtotal - discount
return (
<div>
<p>小計: {subtotal.toLocaleString()}円</p>
<p>割引: -{discount.toLocaleString()}円</p>
<p>合計: {total.toLocaleString()}円</p>
</div>
)
}After:業務ロジックをドメイン層に集約
// domains/order/services/OrderCalculator.ts
export class OrderCalculator {
/** クーポンと会員ランクを加味した割引額を算出する */
static calculateDiscount(
subtotal: number,
coupon: Coupon | null,
memberRank: MemberRank
): number {
const couponDiscount = coupon
? CouponPolicy.apply(coupon, subtotal)
: 0
const rankDiscount = MemberDiscountPolicy.apply(memberRank, subtotal)
return couponDiscount + rankDiscount
}
static calculateTotal(items: OrderItem[]): OrderTotal {
const subtotal = items.reduce((sum, item) =>
sum + item.price * item.quantity, 0
)
const discount = this.calculateDiscount(subtotal, /* ... */)
return { subtotal, discount, total: subtotal - discount }
}
}
// components/CartSummary.tsx(UIは表示に専念)
const CartSummary = ({ items, coupon }: Props) => {
const { subtotal, discount, total } = OrderCalculator.calculateTotal(items)
return (
<div>
<p>小計: {subtotal.toLocaleString()}円</p>
<p>割引: -{discount.toLocaleString()}円</p>
<p>合計: {total.toLocaleString()}円</p>
</div>
)
}After のコードでは、割引のルールが変わってもOrderCalculatorだけを修正すればよく、表示を担うコンポーネントには手を加える必要がありません。この分離によって、ユニットテストも格段に書きやすくなります。UIのレンダリングを気にせず、純粋な関数として業務ロジックをテストできるからです。
ステップ4:境界づけられたコンテキストの設定
「顧客」という言葉が、営業部門では「見込み客を含む」、サポート部門では「契約済みの顧客のみ」を意味する場合があります。このように同じ用語が異なる意味を持つ領域を「境界づけられたコンテキスト」として明確に分けることで、混乱を防ぎます。
フロントエンドでこの考え方がどう活きるか、もう少し具体的に説明します。ある人材紹介サービスの案件では、「ユーザー」という言葉が3つの異なる意味で使われていました。
- 求職者コンテキスト:求人に応募する人。スキルシートや希望条件を持つ。
- 企業コンテキスト:求人を出す企業の担当者。権限レベルや所属部署を持つ。
- 管理コンテキスト:システム管理者。操作ログや権限設定を持つ。
コード上でこれらを同じUser型として扱うと、「このUserにスキルシートのプロパティがあるかないかは、呼び出し元のコンテキストによる」という暗黙の前提が生まれます。これはバグの温床です。
// コンテキストごとに型を分ける
// domains/applicant/models/Applicant.ts
interface Applicant {
id: string
name: string
skillSheet: SkillSheet
preferences: JobPreferences
}
// domains/recruiter/models/Recruiter.ts
interface Recruiter {
id: string
name: string
company: Company
department: string
permissionLevel: PermissionLevel
}このように明確に型を分けることで、「求職者の情報を扱っているつもりが、企業担当者の型が渡されていた」というミスをTypeScriptのコンパイル時に検出できるようになります。
DDD導入時のよくある落とし穴と対処法
私たちがこれまで関わったプロジェクトの経験から、DDD導入時に起きやすい問題をまとめました。最初からうまくいかなかった場面も正直に共有します。
過度な抽象化に走ってしまう問題があります。DDDの概念をすべて忠実に実装しようとすると、コード量が膨らみ、かえって開発速度が落ちることがあります。最初は「ユビキタス言語の統一」と「ドメイン単位のディレクトリ構成」の2つに絞って始めるのが現実的です。AとBのどちらのアプローチが適切か悩む場面は多いですが、一概には言えない部分もあり、チームの規模やプロジェクトの複雑さに応じた判断が求められます。
実際に私たちが経験した失敗例を1つ挙げます。あるSaaSプロジェクトで、すべてのデータ更新を「ドメインイベント」として実装しようとしたことがあります。ボタンのクリックひとつに対して、イベントの発行・購読・ハンドリングと3つのファイルを経由する設計にしてしまい、開発速度が著しく低下しました。結局、イベント駆動が本当に必要な箇所(複数ドメインにまたがる処理)だけに限定し、シンプルな関数呼び出しに戻したところ、チーム全体の生産性が回復しました。
チーム全体への浸透が難しいという課題も見過ごせません。DDDの効果を最大化するには、開発者だけでなく業務側のメンバーもユビキタス言語を使う必要があります。定期的な用語レビューの場を設けることで、言葉のずれを早期に検出できるようになります。
浸透のために効果的だった施策をいくつか紹介します。
- PRレビューでの用語チェック:コードレビュー時に「この変数名はユビキタス言語と一致していますか?」を確認事項に入れる
- 月1回のモデリングセッション:業務担当者とエンジニアが集まり、ドメインモデルの図を一緒に更新する。ホワイトボードに付箋を貼りながら進めると、意外な認識のずれが見つかります
- 用語集のCI連携:ESLintのカスタムルールで、禁止用語(例:ユビキタス言語で「顧客」と決めたのに
userを使っている)を検出する仕組みを導入する
既存コードベースへの段階的導入が難しいという声もよく聞きます。すでに動いているプロジェクトを一気にDDD構成に作り替えるのは現実的ではありません。おすすめは「新機能からDDD構成を適用し、既存コードは改修のタイミングで徐々に移行する」というアプローチです。あるプロジェクトでは、四半期に1つのドメインを選んで集中的にリファクタリングする計画を立て、1年かけて主要な4ドメインの移行を完了しました。
以下は、導入の判断基準となるチェックリストです。
- 業務ルールの変更が頻繁に発生するか
- 画面数が10を超える規模のアプリケーションか
- 複数の業務部門が関わるシステムか
- 今後1年以上の運用・保守が見込まれるか
- 仕様変更のたびに想定外の不具合が発生しているか
3つ以上該当する場合は、DDDの導入による効果が期待できます。逆に、小規模で短期間のプロジェクトでは、DDDの設計コストが見合わないかもしれません。
フロントエンドDDDとテスト戦略
DDDの大きなメリットの1つに、テストの書きやすさがあります。業務ロジックがドメイン層に集約されていれば、UIのレンダリングとは無関係にロジックをテストできるからです。
ドメイン層のユニットテスト
先ほどのOrderCalculatorを例にとると、以下のようなテストが書けます。
describe('OrderCalculator', () => {
describe('割引計算', () => {
it('パーセンテージクーポンで小計の10%が割引される', () => {
const coupon = { type: 'percentage', value: 10 } as Coupon
const discount = OrderCalculator.calculateDiscount(10000, coupon, 'regular')
expect(discount).toBe(1000)
})
it('固定額クーポンが小計を超える場合、小計が上限になる', () => {
const coupon = { type: 'fixed', value: 5000 } as Coupon
const discount = OrderCalculator.calculateDiscount(3000, coupon, 'regular')
expect(discount).toBe(3000)
})
it('ゴールド会員は追加で5%の割引が適用される', () => {
const discount = OrderCalculator.calculateDiscount(10000, null, 'gold')
expect(discount).toBe(500)
})
})
})このテストにはReactのレンダリングもモックサーバーも必要ありません。純粋な入力と出力の検証だけで業務ルールの正しさを担保できます。テストの実行速度も速く、開発中に頻繁に回しても負担になりません。
UIコンポーネントのテストはシンプルに
業務ロジックをドメイン層に移した結果、UIコンポーネントのテストは「正しいデータが表示されているか」の確認に絞れます。複雑な条件分岐のテストはドメイン層で済んでいるため、コンポーネントテストでは表示の確認だけで十分です。
この分離は、フリーランスとしてプロジェクトに参画する際にも助かります。ドメインのテストとUIのテストが明確に分かれていれば、担当範囲の影響を自信を持って確認できるからです。
フリーランスエンジニアがDDDスキルを活かす場面
ここまで技術的な内容を中心にお伝えしてきましたが、フリーランスエンジニアの方にとって、DDDの知見はキャリア面でも大きな武器になります。
案件の提案フェーズでの差別化
クライアントとの初回ミーティングで「御社の業務フローを整理し、それをそのままコードの構造に反映する設計手法を採用します」と提案できるエンジニアは、まだ多くありません。画面のデザインカンプを見て「作れます」と答えるだけでなく、「この業務をどういうドメインに分割すると、将来の仕様変更に強くなりますか」という問いを投げかけられることが、単価交渉の材料にもなります。
実際に私たちが採用面談で重視するのも、「この機能をどう画面に落とすか」よりも「この業務をどう構造化するか」を語れるかどうかです。
既存プロジェクトの改善提案
参画したプロジェクトで「コンポーネントが肥大化していて改修が大変」という課題を発見したとき、DDDの視点を持っていれば具体的な改善案を提示できます。
たとえば、「まず業務ロジックをドメイン層に切り出し、次に四半期ごとに1ドメインずつリファクタリングしていくロードマップ」を提案すれば、クライアントにとっても投資対効果が見えやすくなります。「とりあえずリファクタリングしたい」では予算がつきにくいですが、「注文ドメインの改善で、割引関連の不具合対応工数を月10時間削減できる見込み」と定量的に示せれば、承認を得やすくなるでしょう。
設計ドキュメントとしてのコードの価値
DDDに基づいて設計されたコードベースは、それ自体がドキュメントとして機能します。ディレクトリ構成を見ればドメインの全体像がわかり、型定義を見れば業務ルールが読み取れます。引き継ぎの際にも「このフォルダがこの業務に対応しています」と端的に説明でき、フリーランスとしてプロジェクトを離れる際にもスムーズな引き継ぎが可能です。
DDD導入のスモールスタート:明日からできる3つのこと
DDDに興味を持っていただけた方に向けて、大きな設計変更をせずに明日から始められるアクションを3つご紹介します。
1. 変数名と関数名を業務用語に揃える
data、info、itemといった汎用的な名前を、order、customer、shippingAddressといった業務用語に置き換えるだけでも効果があります。コードを読む人が「これは何のデータか」を即座に理解できるようになります。
2. 1つのコンポーネントから業務ロジックを抽出してみる
プロジェクトの中で最も肥大化しているコンポーネントを1つ選び、その中の業務ロジックを別ファイルに切り出してみてください。すべてを一度にやる必要はありません。1つのコンポーネントで効果を実感できれば、チーム内での推進力が生まれます。
3. 用語集を1枚作る
プロジェクトで使われている業務用語を10個でもよいのでスプレッドシートにまとめ、チームに共有してみてください。「この言葉の意味、実はチーム内で認識が違っていた」という発見が、驚くほど多く出てくるはずです。
まとめ:業務とコードをつなぐ設計が長期的な資産になる
ドメイン駆動設計をフロントエンドに取り入れることで、業務の言葉がそのままコードの構造に反映され、仕様変更への対応力が大きく向上します。初期の設計にはたしかに時間を要しますが、中長期的に見れば保守コストの削減と品質の安定という形で投資を回収できるのではないでしょうか。
重要なのは、完璧なDDDを目指すことではなく、業務を理解した設計を少しずつ取り入れていくことです。まずはユビキタス言語の整理から始めてみることをおすすめします。
フリーランスエンジニアの方にとっては、DDDの知見は「作れる人」から「設計できる人」への転換点になり得ます。クライアントの業務を深く理解し、それを保守しやすいコードに落とし込む力は、案件獲得においても継続契約においても大きなアドバンテージです。
aduceでは、業務理解に基づいたフロントエンド設計やDX推進のご相談を承っています。既存システムの設計見直しや新規開発の設計方針にお悩みの方は、ぜひaduceのお問い合わせはこちらからお気軽にご連絡ください。
