0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DDDは本当に必要か?同じ機能を4つのレベルで実装して検証してみた

Posted at

はじめに

「ドメイン駆動設計(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つです:

  1. 同じロジックの重複 - 修正が5箇所に及ぶ
  2. 複数の入口への対応 - API/Web/CLI/Queueで重複実装
  3. N+1問題と性能劣化 - 後から最適化できない
  4. トランザクション制御 - データ不整合が発生
  5. 段階的な複雑化 - 半年後に200行の巨大メソッド
  6. 外部連携の追加 - テスト不可能、障害に弱い
  7. チーム開発での衝突 - 同じファイルでコンフリクト

これらの問題は、小規模プロジェクトでも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. ロジックの重複を防ぐ - 修正が1箇所で済む
  2. 複数の入口に対応 - 一貫性が保証される
  3. 性能最適化が容易 - 後から改善できる
  4. データ不整合を防ぐ - トランザクション制御が明確
  5. 段階的な複雑化に耐える - 巨大メソッド化を防ぐ
  6. 外部連携を分離 - テスト可能、障害に強い
  7. チーム開発がスムーズ - コンフリクトしない

プロジェクト規模別の選択指針

個人プロジェクト、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+ファイル)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?