はじめに
「ドメイン駆動設計(DDD)って結局必要なの?」
「コントローラーにロジック書くだけじゃダメなの?」
「Serviceクラス作れば十分じゃない?」
こういった疑問、誰もが一度は感じたことがあるのではないでしょうか。
確かに、DDDを採用するとコード量が増えるため、小規模なプロジェクトでは明らかにオーバーエンジニアリングに見えます。
本記事では、ECサイトの注文機能という同じ要件を、4つの異なる設計レベルで実装し、それぞれのメリット・デメリットを実際のコードで検証しました。
TL;DR(結論を先に)
コード量の推移
| 実装レベル | 初期実装 | 機能追加後 | ファイル数 |
|---|---|---|---|
| レベル1: Controller直接 | 80行 | 145行 | 1-2ファイル |
| レベル2: Service分離 | 125行 | 245行 | 4-8ファイル |
| レベル3: Repository追加 | 195行 | 370行 | 8-12ファイル |
| レベル4: 完全DDD | 700行 | 920行 | 20+ファイル |
各レベルが適している場面
| レベル | 適用場面 | チーム規模 | プロジェクト期間 |
|---|---|---|---|
| レベル1 | 個人開発、MVP | 1-2人 | 1-2ヶ月 |
| レベル2 | 小規模SaaS | 2-5人 | 3ヶ月-1年 |
| レベル3 | 中規模プロダクト | 5-10人 | 1-3年 |
| レベル4 | 大規模・長期運用 | 10人以上 | 3年以上 |
本当に重要なのは「テストしやすさ」ではない
よく言われる「レイヤー分離のメリットはテストしやすくなること」という説明だけでは不十分です。
実際に痛みを伴う問題は以下の7つです:
- 同じロジックの重複 - 修正が5箇所に及ぶ
- 複数の入口への対応 - API/Web/CLI/Queueで重複実装
- N+1問題と性能劣化 - 後から最適化できない
- トランザクション制御 - データ不整合が発生
- 段階的な複雑化 - 半年後に200行の巨大メソッド
- 外部連携の追加 - テスト不可能、障害に弱い
- チーム開発での衝突 - 同じファイルでコンフリクト
これらの問題は、小規模プロジェクトでも3-6ヶ月後には確実に顕在化します。
検証シナリオ
実装する機能
ECサイトの注文機能を題材にします。
初期要件:
- 注文作成
- 在庫チェック
- 合計金額計算
段階的に追加される要件:
- クーポン割引機能
- ポイント付与
- 在庫不足時の通知
この要件の変化を通じて、各レベルの拡張性と保守性を評価します。
検証結果の詳細
1. 同じロジックの重複問題
レベル1での問題
// OrderController.php - 注文作成
public function store(Request $request)
{
$total = 0;
foreach ($items as $item) {
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
// クーポン適用
if ($couponCode) {
$coupon = Coupon::where('code', $couponCode)->first();
if ($coupon->type === 'percentage') {
$discount = $total * ($coupon->value / 100);
}
$total -= $discount;
}
}
// CartController.php - カート合計表示
public function summary(Request $request)
{
// ❌ 同じロジックをコピペ
$total = 0;
foreach ($cart->items as $item) {
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
// クーポン適用(また同じコード)
if ($request->coupon_code) {
$coupon = Coupon::where('code', $request->coupon_code)->first();
// ... 同じ処理
}
}
// QuoteController.php - 見積もり作成
public function create(Request $request)
{
// ❌ また同じロジック...
}
影響: クーポンロジック変更時に3-4箇所の修正が必要。1箇所でも忘れると不整合バグ。
レベル2以上での解決
// CouponService.php(1箇所だけ)
class CouponService
{
public function applyCoupon(?string $code, float $total): array
{
// ✅ ロジックは1箇所だけ
// 修正もここだけで完結
}
}
// すべてのControllerで再利用
$pricing = $this->couponService->applyCoupon($code, $total);
効果: 修正は1箇所、すべての機能で一貫性が保証される。
2. 複数の入口への対応
実際のプロダクトでは、同じビジネスロジックを複数の入口から実行します。
レベル1での問題
// API用
class OrderApiController { /* 注文ロジック実装 */ }
// Web用
class OrderWebController { /* 同じロジックを再実装 */ }
// CLI(バッチ処理)
class ImportOrdersCommand { /* また同じロジック */ }
// Queue(非同期ジョブ)
class ProcessBulkOrderJob { /* また同じロジック */ }
// Webhook(外部連携)
class WebhookController { /* また同じロジック */ }
問題: ビジネスルール変更時に5箇所すべて修正が必要。Web版だけ修正忘れで不整合。
レベル2以上での解決
// OrderService.php(1箇所だけ)
class OrderService
{
public function createOrder(array $data): Order
{
// ビジネスロジックは1箇所
}
}
// すべての入口から同じServiceを使う
class OrderApiController {
public function store() {
return $this->orderService->createOrder($request->validated());
}
}
class OrderWebController {
public function store() {
return $this->orderService->createOrder($request->validated());
}
}
class ImportOrdersCommand {
public function handle(OrderService $service) {
$service->createOrder($data);
}
}
効果: 新しい入口(GraphQLなど)を追加しても、同じServiceを使うだけ。
3. N+1問題と性能劣化
レベル1での問題
public function store(Request $request)
{
// ❌ 商品1つごとにクエリ発行
foreach ($items as $item) {
$product = Product::find($item['product_id']); // クエリ1
if ($product->stock < $item['quantity']) { /* ... */ }
}
// ❌ また同じ商品を取得
foreach ($items as $item) {
$product = Product::find($item['product_id']); // クエリ2
$total += $product->price * $item['quantity'];
}
// ❌ 注文明細作成でも取得
foreach ($items as $item) {
$product = Product::find($item['product_id']); // クエリ3
$order->items()->create([...]);
}
// 10商品なら 10 × 3 = 30クエリ!
}
問題: ロジックが散在しているため、後から最適化が困難。
レベル2以上での解決
class OrderService
{
public function createOrder(array $data): Order
{
// ✅ 商品を一度にまとめて取得
$productIds = array_column($data['items'], 'product_id');
$products = $this->productRepository->findByIds($productIds); // 1クエリ
// メモリ上で処理
// 10商品の注文でも3-4クエリで済む
}
}
効果: RepositoryにfindByIds()を実装するだけで、すべてのControllerで自動的に高速化。
4. トランザクション制御の難しさ
レベル1での問題
public function store(Request $request)
{
// ❌ トランザクション境界が不明確
$order = Order::create([...]);
// 在庫減少(途中で失敗したら?)
foreach ($items as $item) {
Product::find($item['product_id'])->decrement('stock', $item['quantity']);
}
// ポイント付与(ここで失敗したら?)
$customer->increment('points', $points);
// メール送信(失敗したら?)
Mail::to($customer)->send(new OrderConfirmation($order));
// どこかで失敗 → データ不整合
}
実際に起きる問題:
- 注文は作成されたのに在庫は減っていない
- ポイントは付与されたのにメールは送信されていない
レベル2以上での解決
class OrderService
{
public function createOrder(array $data): Order
{
// ✅ トランザクション境界が明確
DB::beginTransaction();
try {
$order = Order::create([...]);
$this->decrementStock($data['items']);
$this->awardPoints($data['customer_id'], $points);
DB::commit();
// ✅ トランザクション外で副作用
event(new OrderCreated($order));
return $order;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
}
効果: 失敗時は確実にロールバック、メール送信は成功後だけ実行。
5. 段階的な複雑化への対応
レベル1: 6ヶ月後の悪夢
class OrderController extends Controller
{
public function store(Request $request)
{
// ❌ 200行の巨大メソッド
// 在庫チェック(30行)
// 合計計算(40行)
// クーポン処理(35行)
// ポイント計算(25行)
// 送料計算(20行)
// 決済手数料(15行)
// ギフトラッピング(...)
// 配送日時指定(...)
// ❌ 誰もこのコードを理解できない
// ❌ 修正するたびに他の機能が壊れる
// ❌ テストが書けない
}
}
レベル2以上での解決
class OrderService
{
public function createOrder(array $data): Order
{
// ✅ 各機能は独立したServiceに委譲
$pricing = $this->couponService->apply($data);
$points = $this->pointService->calculate($pricing);
$shipping = $this->shippingService->calculate($data);
// OrderServiceは常に30行以内
}
}
// 各Serviceは独立してテスト可能
// - ProductService
// - CouponService
// - PointService
// - ShippingService
効果: 新機能追加時も、新しいServiceを作るだけ。既存コードへの影響最小。
6. 外部連携の追加
レベル1での問題
public function store(Request $request)
{
$order = Order::create([...]);
// ❌ 外部サービスを直接呼び出し
$inventoryApi = new InventoryApiClient();
$inventoryApi->decrementStock($items);
$shippingApi = new ShippingApiClient();
$shippingApi->createShipment($order);
// ❌ テストが不可能(外部API必須)
// ❌ APIが遅いとユーザーが待たされる
// ❌ API障害時の対応が難しい
}
レベル2以上での解決
class OrderService
{
public function createOrder(array $data): Order
{
$order = Order::create([...]);
// ✅ イベント発行だけ
event(new OrderCreated($order));
return $order;
}
}
// 外部連携はリスナーで分離
class NotifyInventorySystem
{
public function handle(OrderCreated $event)
{
$this->inventoryApi->decrementStock($event->order->items);
}
}
// ✅ 各リスナーを非同期キューで実行可能
// ✅ API障害時はリトライ
// ✅ テスト時はリスナーを無効化できる
7. チーム開発での衝突
レベル1での問題
// OrderController.php
// 開発者A: クーポン機能を追加中(30行追加)
// 開発者B: ポイント機能を追加中(20行追加)
// ❌ Gitでコンフリクト発生
// ❌ マージが難しい
// ❌ 統合後にバグが混入しやすい
レベル2以上での解決
// 開発者A: CouponService.php を作成
class CouponService { /* 独立して開発 */ }
// 開発者B: PointService.php を作成
class PointService { /* 独立して開発 */ }
// OrderService.php(統合は最後)
class OrderService
{
public function createOrder(array $data): Order
{
$pricing = $this->couponService->apply(...);
$points = $this->pointService->calculate(...);
// ✅ コンフリクトしない
// ✅ 各機能を独立してテスト
}
}
比較表:見通しの良さ
| 項目 | レベル1 | レベル2 | レベル3 | レベル4 |
|---|---|---|---|---|
| 1ファイルの行数 | 145行 ❌ | 平均30行 ✅ | 平均30行 ✅ | 平均40行 ✅ |
| 責任の明確さ | ❌ すべて混在 | ⚠️ まあまあ | ✅ 明確 | ✅ 非常に明確 |
| ビジネスロジックの場所 | ❌ Controller | ⚠️ Service | ⚠️ Service | ✅ Domain Model |
| 新メンバーの理解 | ✅ 簡単 | ✅ まあまあ | ⚠️ やや難しい | ❌ 難しい |
| ロジック重複 | ❌ 3-5箇所に散在 | ✅ 1箇所に集約 | ✅ 1箇所に集約 | ✅ 1箇所に集約 |
| 複数入口対応 | ❌ 各入口で再実装 | ✅ Service再利用 | ✅ Service再利用 | ✅ UseCase再利用 |
| 性能最適化 | ❌ N+1、困難 | ⚠️ 可能 | ✅ 容易 | ✅ 非常に容易 |
| トランザクション | ❌ 不整合リスク | ✅ 明確 | ✅ 明確 | ✅ 非常に明確 |
修正時の影響範囲
シナリオ: 「クーポン割引計算ロジックを変更」
| レベル | 修正箇所 | 影響範囲 | リスク |
|---|---|---|---|
| レベル1 | Controller内30行 | 全体に波及 | 高 |
| レベル2 | CouponService内10行 | 限定的 | 低 |
| レベル3 | CouponService内10行 | 限定的 | 低 |
| レベル4 | Couponドメインモデル5行 | 最小限 | 最低 |
実践的な推奨
ハイブリッド戦略(現実的なアプローチ)
実際のプロジェクトでは、同一プロジェクト内でレベルを混在させるのが現実的です。
// 80%の機能: シンプルに(レベル1-2)
class ProfileController
{
public function update(Request $request)
{
$request->user()->update($request->validated());
return back();
}
}
// 15%の機能: しっかり設計(レベル2-3)
class OrderController
{
public function store(Request $request)
{
$order = $this->orderService->createOrder($request->validated());
return new OrderResource($order);
}
}
// 5%の機能: 完璧に(レベル4)
class PaymentController
{
public function process(Request $request)
{
$command = ProcessPaymentCommand::fromRequest($request);
$result = $this->paymentUseCase->execute($command);
return response()->json($result);
}
}
原則:
- シンプルなCRUDは素直に実装
- 重要なビジネスロジックはService分離
- 超重要・複雑な機能だけDDD
まとめ
DDDが必要な本当の理由
「テストしやすくなる」だけでは不十分です。本当の理由は:
- ロジックの重複を防ぐ - 修正が1箇所で済む
- 複数の入口に対応 - 一貫性が保証される
- 性能最適化が容易 - 後から改善できる
- データ不整合を防ぐ - トランザクション制御が明確
- 段階的な複雑化に耐える - 巨大メソッド化を防ぐ
- 外部連携を分離 - テスト可能、障害に強い
- チーム開発がスムーズ - コンフリクトしない
プロジェクト規模別の選択指針
個人プロジェクト、MVP(1-2ヶ月)
→ レベル1: コード量最小、速度最速
小規模SaaS(3-6ヶ月、2-3人)
→ レベル2: コード量2-3倍、保守性良好
中規模プロダクト(1-2年、5-10人)
→ レベル3: コード量4-5倍、保守性優秀
大規模・長期運用(3年以上、10人以上)
→ レベル4: コード量8-10倍、品質最高
最後に
「DDDは複雑すぎる」という批判は正しい面もあります。しかし、適切なレイヤー分離は、小規模プロジェクトでも3-6ヶ月後には確実に価値を生みます。
重要なのは、最初から完璧を目指さないこと。シンプルに始めて、必要になったら段階的にリファクタリングする。これが現実的なアプローチです。
付録:実装コード全文
詳細なコード実装は以下に掲載しています。
レベル1: Controller直接
実装コードを見る
// routes/api.php
Route::post('/orders', [OrderController::class, 'store']);
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'customer_id' => 'required|exists:customers,id',
'items' => 'required|array',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
]);
// 在庫チェック
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
return response()->json([
'error' => "Insufficient stock for {$product->name}"
], 400);
}
}
// 合計金額計算
$total = 0;
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
// 注文作成
$order = Order::create([
'customer_id' => $validated['customer_id'],
'total' => $total,
'status' => 'pending',
]);
// 注文明細作成
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $product->price,
]);
// 在庫減少
$product->decrement('stock', $item['quantity']);
}
return response()->json($order->load('items'), 201);
}
}
// app/Models/Order.php
class Order extends Model
{
protected $fillable = ['customer_id', 'total', 'status'];
public function items()
{
return $this->hasMany(OrderItem::class);
}
}
コード量: 約80行
レベル2: Service分離
実装コードを見る
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
public function __construct(
private OrderService $orderService
) {}
public function store(StoreOrderRequest $request)
{
try {
$order = $this->orderService->createOrder(
$request->validated()
);
return response()->json($order->load('items'), 201);
} catch (InsufficientStockException $e) {
return response()->json([
'error' => $e->getMessage(),
'products' => $e->getProducts()
], 400);
}
}
}
// app/Services/OrderService.php
class OrderService
{
public function createOrder(array $data): Order
{
// 在庫チェック
$this->checkStock($data['items']);
// 合計金額計算
$total = $this->calculateTotal($data['items']);
DB::beginTransaction();
try {
// 注文作成
$order = Order::create([
'customer_id' => $data['customer_id'],
'total' => $total,
'status' => 'pending',
]);
// 注文明細作成
$this->createOrderItems($order, $data['items']);
// 在庫減少
$this->decrementStock($data['items']);
DB::commit();
return $order;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
private function checkStock(array $items): void
{
$outOfStock = [];
foreach ($items as $item) {
$product = Product::find($item['product_id']);
if ($product->stock < $item['quantity']) {
$outOfStock[] = $product->name;
}
}
if (!empty($outOfStock)) {
throw new InsufficientStockException($outOfStock);
}
}
private function calculateTotal(array $items): float
{
$total = 0;
foreach ($items as $item) {
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
return $total;
}
private function createOrderItems(Order $order, array $items): void
{
foreach ($items as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'price' => $product->price,
]);
}
}
private function decrementStock(array $items): void
{
foreach ($items as $item) {
Product::find($item['product_id'])
->decrement('stock', $item['quantity']);
}
}
}
コード量: 約125行(4ファイル)
レベル3: Repository追加
実装コードを見る
// app/Repositories/OrderRepository.php
class OrderRepository
{
public function create(array $data): Order
{
return Order::create($data);
}
public function save(Order $order): Order
{
$order->save();
return $order;
}
public function findById(int $id): ?Order
{
return Order::with('items')->find($id);
}
}
// app/Repositories/ProductRepository.php
class ProductRepository
{
public function findById(int $id): ?Product
{
return Product::find($id);
}
public function findByIds(array $ids): Collection
{
return Product::whereIn('id', $ids)->get();
}
public function decrementStock(int $productId, int $quantity): void
{
Product::where('id', $productId)
->decrement('stock', $quantity);
}
public function checkStock(int $productId, int $quantity): bool
{
$product = $this->findById($productId);
return $product && $product->stock >= $quantity;
}
}
// app/Services/OrderService.php
class OrderService
{
public function __construct(
private OrderRepository $orderRepository,
private ProductRepository $productRepository
) {}
public function createOrder(array $data): Order
{
// 在庫チェック
$this->checkStock($data['items']);
// 合計金額計算(最適化版)
$total = $this->calculateTotal($data['items']);
DB::beginTransaction();
try {
// Repositoryを使って注文作成
$order = $this->orderRepository->create([
'customer_id' => $data['customer_id'],
'total' => $total,
'status' => 'pending',
]);
$this->createOrderItems($order, $data['items']);
$this->decrementStock($data['items']);
DB::commit();
return $order;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
private function calculateTotal(array $items): float
{
$total = 0;
// 一度にまとめて取得(N+1問題の解消)
$productIds = array_column($items, 'product_id');
$products = $this->productRepository->findByIds($productIds);
foreach ($items as $item) {
$product = $products->firstWhere('id', $item['product_id']);
$total += $product->price * $item['quantity'];
}
return $total;
}
}
コード量: 約195行(5ファイル)
レベル4: 完全DDD
実装コードを見る
// app/Domain/Models/Order.php
class Order
{
private ?OrderId $id;
private CustomerId $customerId;
private OrderItems $items;
private Money $subtotal;
private Money $discount;
private Money $total;
private OrderStatus $status;
private function __construct(
CustomerId $customerId,
OrderItems $items
) {
$this->id = null;
$this->customerId = $customerId;
$this->items = $items;
$this->subtotal = $items->calculateTotal();
$this->discount = Money::zero();
$this->total = $this->subtotal;
$this->status = OrderStatus::pending();
}
public static function create(
CustomerId $customerId,
OrderItems $items
): self {
if ($items->isEmpty()) {
throw new DomainException('Order must have at least one item');
}
return new self($customerId, $items);
}
public function applyCoupon(Coupon $coupon): void
{
if (!$coupon->isValidFor($this->subtotal)) {
throw new DomainException('Coupon is not valid');
}
$this->discount = $coupon->calculateDiscount($this->subtotal);
$this->recalculateTotal();
}
private function recalculateTotal(): void
{
$this->total = $this->subtotal->subtract($this->discount);
}
public function getTotal(): Money
{
return $this->total;
}
}
// app/Domain/ValueObjects/Money.php
class Money
{
private float $amount;
private string $currency;
public function __construct(float $amount, string $currency = 'JPY')
{
if ($amount < 0) {
throw new DomainException('Amount cannot be negative');
}
$this->amount = $amount;
$this->currency = $currency;
}
public static function zero(): self
{
return new self(0);
}
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): self
{
$this->assertSameCurrency($other);
return new self(max(0, $this->amount - $other->amount), $this->currency);
}
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw new DomainException('Currency mismatch');
}
}
public function value(): float
{
return $this->amount;
}
}
// app/Application/UseCases/PlaceOrderUseCase.php
class PlaceOrderUseCase
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private ProductRepositoryInterface $productRepository,
private StockService $stockService
) {}
public function execute(PlaceOrderCommand $command): OrderId
{
DB::beginTransaction();
try {
$customerId = new CustomerId($command->customerId);
$orderItems = $this->buildOrderItems($command->items);
// ドメインサービスで在庫チェック
$this->stockService->checkAvailability($orderItems);
// ドメインモデルで注文生成
$order = Order::create($customerId, $orderItems);
// 保存
$orderId = $this->orderRepository->nextIdentity();
$this->orderRepository->save($order);
DB::commit();
return $orderId;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
}
コード量: 約700行(20+ファイル)