🔰

Laravel開発者のためのドメイン駆動設計(DDD)ガイド

に公開

DDDの基礎概念

ドメイン駆動設計とは

ドメイン駆動設計(Domain-Driven Design、DDD)は、ビジネスロジックを中心に据えたソフトウェア設計手法です。技術的な実装詳細よりも、ビジネスの問題領域(ドメイン)に焦点を当てて設計を行います。

DDDの核となる考え方

DDDでは以下の原則を重視します:

ユビキタス言語(Ubiquitous Language)
開発者とビジネス担当者が共通の言葉で会話できるよう、ドメインの概念を明確に定義します。

境界付けられたコンテキスト(Bounded Context)
大きなシステムを論理的に分離し、それぞれのコンテキスト内で一貫したモデルを維持します。

ドメインの複雑さに集中
技術的な詳細をドメインロジックから分離し、ビジネスルールの表現に集中します。


なぜLaravelでDDDを採用するのか

従来のLaravelの課題

Laravel の標準的な MVC アーキテクチャは、シンプルな CRUD 操作には適していますが、複雑なビジネスロジックが増加すると以下の問題が発生します:

DDDを採用するメリット

1. ビジネスルールの明確化
コードを読むだけで、ビジネスの仕組みが理解できるようになります。

2. 変更への対応力向上
ビジネスルール変更の影響範囲を限定でき、安全な変更が可能になります。

3. チーム間コミュニケーションの改善
開発者と業務担当者が同じ概念と言葉で議論できるようになります。

4. テスタビリティの向上
ビジネスロジックが独立し、データベースなしでもドメインロジックのテストが可能になります。


アーキテクチャの比較

従来のLaravel構造

app/
├── Http/
│   ├── Controllers/     # アクション処理 + ビジネスロジック
│   └── Requests/        # 入力検証
├── Models/              # DB操作 + ビジネスロジック
└── Providers/           # サービスプロバイダ

DDD採用後のLaravel構造

app/
├── Domain/                    # ドメイン層(ビジネスロジックの核心)
│   ├── Post/
│   │   ├── Entities/          # エンティティ(ビジネスオブジェクト)
│   │   ├── ValueObjects/      # 値オブジェクト
│   │   ├── Repositories/      # データアクセス抽象化
│   │   └── Services/          # ドメインサービス
│   └── User/
├── Application/               # アプリケーション層
│   └── Services/              # ユースケース実装
├── Infrastructure/            # インフラストラクチャ層
│   └── Repositories/          # データアクセス実装
├── Http/                      # プレゼンテーション層
│   ├── Controllers/           # HTTPリクエスト処理
│   └── Requests/              # 入力検証
└── Models/                    # Eloquentモデル(DBマッピング)

レイヤーアーキテクチャの詳細


DDDの主要コンポーネント

1. エンティティ(Entities)

エンティティは、一意のIDを持ち、ライフサイクルを通じて同一性が保たれるドメインオブジェクトです。

従来のEloquentモデルとの違い

従来のEloquentモデル(問題のあるパターン):

class Post extends Model
{
    protected $fillable = ['title', 'content', 'user_id'];
    
    public function user()
    {
        return $this->belongsTo(User::class);
    }
    
    // 問題:DBアクセスとビジネスロジックが混在
    public function publish()
    {
        $this->status = 'published';
        $this->published_at = now();
        $this->save(); // DB操作がビジネスロジックに混入
    }
}

DDDのエンティティ(改善されたパターン):

namespace App\Domain\Post\Entities;

/**
 * 投稿エンティティ
 * ビジネスロジックのみに専念し、永続化の詳細は知らない
 */
class Post
{
    private PostId $id;
    private PostTitle $title;
    private PostContent $content;
    private UserId $authorId;
    private PostStatus $status;
    private ?\DateTimeImmutable $publishedAt;
    
    public function __construct(
        PostId $id,
        PostTitle $title,
        PostContent $content,
        UserId $authorId
    ) {
        $this->id = $id;
        $this->title = $title;
        $this->content = $content;
        $this->authorId = $authorId;
        $this->status = PostStatus::draft(); // デフォルトは下書き
        $this->publishedAt = null;
    }
    
    /**
     * 投稿を公開する(ビジネスルール)
     * - すでに公開済みの場合は何もしない
     * - 公開時刻を記録する
     */
    public function publish(): void
    {
        if ($this->status->isPublished()) {
            return; // すでに公開済み
        }
        
        $this->status = PostStatus::published();
        $this->publishedAt = new \DateTimeImmutable();
        
        // DBには保存しない(それはリポジトリの役割)
    }
    
    /**
     * 投稿を下書きに戻す(ビジネスルール)
     */
    public function unpublish(): void
    {
        if ($this->status->isDraft()) {
            return;
        }
        
        $this->status = PostStatus::draft();
        $this->publishedAt = null;
    }
    
    /**
     * 公開可能かチェック(ビジネスルール)
     */
    public function canPublish(): bool
    {
        return !$this->title->isEmpty() && !$this->content->isEmpty();
    }
    
    // ゲッターメソッド(不変性を保つ)
    public function id(): PostId { return $this->id; }
    public function title(): PostTitle { return $this->title; }
    public function content(): PostContent { return $this->content; }
    public function authorId(): UserId { return $this->authorId; }
    public function status(): PostStatus { return $this->status; }
    public function publishedAt(): ?\DateTimeImmutable { return $this->publishedAt; }
    
    public function isPublished(): bool
    {
        return $this->status->isPublished();
    }
}

2. 値オブジェクト(Value Objects)

値オブジェクトは、属性によってのみ同一性が決まる不変のオブジェクトです。

namespace App\Domain\Post\ValueObjects;

/**
 * 投稿タイトル値オブジェクト
 * ビジネスルール:1-100文字、空文字不可
 */
final class PostTitle
{
    private string $value;
    
    public function __construct(string $value)
    {
        $this->validateTitle($value);
        $this->value = $value;
    }
    
    private function validateTitle(string $value): void
    {
        if (empty(trim($value))) {
            throw new \DomainException('タイトルは空にできません');
        }
        
        if (mb_strlen($value) > 100) {
            throw new \DomainException('タイトルは100文字以内で入力してください');
        }
        
        if (mb_strlen($value) < 1) {
            throw new \DomainException('タイトルは1文字以上で入力してください');
        }
    }
    
    public function value(): string
    {
        return $this->value;
    }
    
    public function isEmpty(): bool
    {
        return empty(trim($this->value));
    }
    
    /**
     * 値オブジェクトの比較(同値性)
     */
    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
    
    /**
     * 文字列としても利用可能
     */
    public function __toString(): string
    {
        return $this->value;
    }
}
namespace App\Domain\Post\ValueObjects;

/**
 * 投稿ステータス値オブジェクト
 */
final class PostStatus
{
    private const DRAFT = 'draft';
    private const PUBLISHED = 'published';
    
    private string $value;
    
    private function __construct(string $value)
    {
        $this->value = $value;
    }
    
    public static function draft(): self
    {
        return new self(self::DRAFT);
    }
    
    public static function published(): self
    {
        return new self(self::PUBLISHED);
    }
    
    public function isDraft(): bool
    {
        return $this->value === self::DRAFT;
    }
    
    public function isPublished(): bool
    {
        return $this->value === self::PUBLISHED;
    }
    
    public function value(): string
    {
        return $this->value;
    }
    
    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }
}

3. リポジトリ(Repositories)

リポジトリは、エンティティの永続化を抽象化し、ドメイン層から技術的詳細を隠蔽します。

リポジトリパターンの構造

インターフェース(ドメイン層):

namespace App\Domain\Post\Repositories;

use App\Domain\Post\Entities\Post;
use App\Domain\Post\ValueObjects\PostId;
use App\Domain\Post\ValueObjects\UserId;

/**
 * 投稿リポジトリインターフェース
 * ドメイン層は実装の詳細を知らない
 */
interface PostRepositoryInterface
{
    /**
     * IDで投稿を取得
     */
    public function findById(PostId $id): ?Post;
    
    /**
     * ユーザーの投稿一覧を取得
     */
    public function findByAuthor(UserId $authorId): array;
    
    /**
     * 公開済み投稿を取得
     */
    public function findPublished(int $limit = 10, int $offset = 0): array;
    
    /**
     * 投稿を保存(新規作成・更新共通)
     */
    public function save(Post $post): void;
    
    /**
     * 投稿を削除
     */
    public function delete(PostId $id): void;
    
    /**
     * 次のIDを生成
     */
    public function nextId(): PostId;
}

実装(インフラストラクチャ層):

namespace App\Infrastructure\Repositories;

use App\Domain\Post\Entities\Post;
use App\Domain\Post\Repositories\PostRepositoryInterface;
use App\Domain\Post\ValueObjects\{PostId, PostTitle, PostContent, UserId, PostStatus};
use App\Models\Post as PostModel;
use Illuminate\Support\Str;

/**
 * Eloquentを使用した投稿リポジトリ実装
 */
class EloquentPostRepository implements PostRepositoryInterface
{
    public function findById(PostId $id): ?Post
    {
        $model = PostModel::find($id->value());
        
        return $model ? $this->toDomainEntity($model) : null;
    }
    
    public function findByAuthor(UserId $authorId): array
    {
        return PostModel::where('user_id', $authorId->value())
            ->orderBy('created_at', 'desc')
            ->get()
            ->map(fn($model) => $this->toDomainEntity($model))
            ->all();
    }
    
    public function findPublished(int $limit = 10, int $offset = 0): array
    {
        return PostModel::where('status', 'published')
            ->orderBy('published_at', 'desc')
            ->limit($limit)
            ->offset($offset)
            ->get()
            ->map(fn($model) => $this->toDomainEntity($model))
            ->all();
    }
    
    public function save(Post $post): void
    {
        $model = PostModel::firstOrNew(['id' => $post->id()->value()]);
        
        // ドメインエンティティからEloquentモデルへの変換
        $model->id = $post->id()->value();
        $model->title = $post->title()->value();
        $model->content = $post->content()->value();
        $model->user_id = $post->authorId()->value();
        $model->status = $post->status()->value();
        $model->published_at = $post->publishedAt();
        
        $model->save();
    }
    
    public function delete(PostId $id): void
    {
        PostModel::destroy($id->value());
    }
    
    public function nextId(): PostId
    {
        return new PostId(Str::uuid()->toString());
    }
    
    /**
     * Eloquentモデルからドメインエンティティへの変換
     */
    private function toDomainEntity(PostModel $model): Post
    {
        $post = new Post(
            new PostId($model->id),
            new PostTitle($model->title),
            new PostContent($model->content),
            new UserId($model->user_id)
        );
        
        // プライベートプロパティの設定(リフレクションまたはファクトリメソッド使用)
        $this->setPostStatus($post, $model->status);
        $this->setPublishedAt($post, $model->published_at);
        
        return $post;
    }
    
    // ヘルパーメソッド実装...
}

4. アプリケーションサービス(Application Services)

アプリケーションサービスは、ユースケースを実装し、ドメインオブジェクトを協調させる役割を担います。

namespace App\Application\Services;

use App\Domain\Post\Entities\Post;
use App\Domain\Post\Repositories\PostRepositoryInterface;
use App\Domain\Post\ValueObjects\{PostTitle, PostContent, UserId};

/**
 * 投稿関連のユースケースを実装
 */
class PostApplicationService
{
    private PostRepositoryInterface $postRepository;
    
    public function __construct(PostRepositoryInterface $postRepository)
    {
        $this->postRepository = $postRepository;
    }
    
    /**
     * 投稿作成ユースケース
     */
    public function createPost(CreatePostRequest $request): CreatePostResponse
    {
        // 1. 値オブジェクトの作成(バリデーションも同時実行)
        $title = new PostTitle($request->title);
        $content = new PostContent($request->content);
        $authorId = new UserId($request->authorId);
        
        // 2. エンティティの作成
        $postId = $this->postRepository->nextId();
        $post = new Post($postId, $title, $content, $authorId);
        
        // 3. 即座に公開する場合の処理
        if ($request->shouldPublish && $post->canPublish()) {
            $post->publish();
        }
        
        // 4. 永続化
        $this->postRepository->save($post);
        
        return new CreatePostResponse($post);
    }
    
    /**
     * 投稿公開ユースケース
     */
    public function publishPost(PublishPostRequest $request): PublishPostResponse
    {
        // 1. 投稿の取得
        $postId = new PostId($request->postId);
        $post = $this->postRepository->findById($postId);
        
        if (!$post) {
            throw new PostNotFoundException("投稿が見つかりません: {$request->postId}");
        }
        
        // 2. ビジネスルールの実行
        if (!$post->canPublish()) {
            throw new PostCannotBePublishedException('この投稿は公開できません');
        }
        
        $post->publish();
        
        // 3. 永続化
        $this->postRepository->save($post);
        
        return new PublishPostResponse($post);
    }
    
    /**
     * 投稿一覧取得ユースケース
     */
    public function getPublishedPosts(GetPublishedPostsRequest $request): GetPublishedPostsResponse
    {
        $posts = $this->postRepository->findPublished(
            $request->limit,
            $request->offset
        );
        
        return new GetPublishedPostsResponse($posts);
    }
}

5. コントローラー(Controllers)

DDDを採用したコントローラーは、HTTPリクエスト/レスポンスの処理に専念します。

namespace App\Http\Controllers;

use App\Application\Services\PostApplicationService;
use App\Application\Requests\{CreatePostRequest, PublishPostRequest};
use App\Http\Requests\StorePostRequest;
use Illuminate\Support\Facades\Auth;

class PostController extends Controller
{
    private PostApplicationService $postService;
    
    public function __construct(PostApplicationService $postService)
    {
        $this->postService = $postService;
        $this->middleware('auth')->except(['index', 'show']);
    }
    
    /**
     * 投稿一覧表示
     */
    public function index()
    {
        $request = new GetPublishedPostsRequest(
            limit: 10,
            offset: 0
        );
        
        $response = $this->postService->getPublishedPosts($request);
        
        return view('posts.index', [
            'posts' => $response->posts
        ]);
    }
    
    /**
     * 投稿作成
     */
    public function store(StorePostRequest $request)
    {
        try {
            // HTTPリクエストからアプリケーションリクエストへの変換
            $createRequest = new CreatePostRequest(
                title: $request->validated('title'),
                content: $request->validated('content'),
                authorId: Auth::id(),
                shouldPublish: $request->has('publish')
            );
            
            // ユースケース実行
            $response = $this->postService->createPost($createRequest);
            
            return redirect()
                ->route('posts.show', $response->post->id()->value())
                ->with('success', '投稿が作成されました');
                
        } catch (\DomainException $e) {
            // ドメイン例外のハンドリング
            return back()
                ->withInput()
                ->withErrors(['error' => $e->getMessage()]);
        }
    }
}

実践例:ブログシステム

実際のブログシステムを例に、DDDの実装を段階的に見ていきましょう。

システム要件

1. ドメインモデルの設計

ドメイン概念の整理

完全なエンティティ実装

namespace App\Domain\Post\Entities;

use App\Domain\Post\ValueObjects\{PostId, PostTitle, PostContent, PostStatus};
use App\Domain\User\ValueObjects\UserId;

/**
 * 投稿エンティティ
 * 
 * ビジネスルール:
 * - 作成時は下書き状態
 * - 公開には有効なタイトルと本文が必要
 * - 作者のみが編集可能
 */
final class Post
{
    private PostId $id;
    private PostTitle $title;
    private PostContent $content;
    private UserId $authorId;
    private PostStatus $status;
    private ?\DateTimeImmutable $publishedAt;
    private \DateTimeImmutable $createdAt;
    private \DateTimeImmutable $updatedAt;
    
    public function __construct(
        PostId $id,
        PostTitle $title,
        PostContent $content,
        UserId $authorId
    ) {
        $this->id = $id;
        $this->title = $title;
        $this->content = $content;
        $this->authorId = $authorId;
        $this->status = PostStatus::draft();
        $this->publishedAt = null;
        $this->createdAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
    }
    
    /**
     * 投稿を公開する
     * 
     * @throws PostCannotBePublishedException
     */
    public function publish(): void
    {
        if ($this->status->isPublished()) {
            return; // すでに公開済み
        }
        
        if (!$this->canPublish()) {
            throw new PostCannotBePublishedException(
                'タイトルと本文の両方が入力されている必要があります'
            );
        }
        
        $this->status = PostStatus::published();
        $this->publishedAt = new \DateTimeImmutable();
        $this->updatedAt = new \DateTimeImmutable();
        
        // ドメインイベントの発行(オプション)
        // DomainEvents::raise(new PostPublishedEvent($this));
    }
    
    /**
     * 投稿を下書きに戻す
     */
    public function unpublish(): void
    {
        if ($this->status->isDraft()) {
            return;
        }
        
        $this->status = PostStatus::draft();
        $this->publishedAt = null;
        $this->updatedAt = new \DateTimeImmutable();
    }
    
    /**
     * 投稿内容を更新する
     */
    public function updateContent(PostTitle $title, PostContent $content): void
    {
        $this->title = $title;
        $this->content = $content;
        $this->updatedAt = new \DateTimeImmutable();
    }
    
    /**
     * 公開可能かチェック
     */
    public function canPublish(): bool
    {
        return !$this->title->isEmpty() && !$this->content->isEmpty();
    }
    
    /**
     * 指定ユーザーが編集権限を持つかチェック
     */
    public function canEditBy(UserId $userId): bool
    {
        return $this->authorId->equals($userId);
    }
    
    // ゲッターメソッド
    public function id(): PostId { return $this->id; }
    public function title(): PostTitle { return $this->title; }
    public function content(): PostContent { return $this->content; }
    public function authorId(): UserId { return $this->authorId; }
    public function status(): PostStatus { return $this->status; }
    public function publishedAt(): ?\DateTimeImmutable { return $this->publishedAt; }
    public function createdAt(): \DateTimeImmutable { return $this->createdAt; }
    public function updatedAt(): \DateTimeImmutable { return $this->updatedAt; }
    
    public function isPublished(): bool
    {
        return $this->status->isPublished();
    }
    
    public function isDraft(): bool
    {
        return $this->status->isDraft();
    }
}

2. リポジトリの完全実装

namespace App\Infrastructure\Repositories;

use App\Domain\Post\Entities\Post;
use App\Domain\Post\Repositories\PostRepositoryInterface;
use App\Domain\Post\ValueObjects\{PostId, PostTitle, PostContent, PostStatus};
use App\Domain\User\ValueObjects\UserId;
use App\Models\Post as PostModel;
use Illuminate\Support\Str;

class EloquentPostRepository implements PostRepositoryInterface
{
    public function nextId(): PostId
    {
        return new PostId(Str::uuid()->toString());
    }
    
    public function findById(PostId $id): ?Post
    {
        $model = PostModel::find($id->value());
        
        return $model ? $this->toDomainEntity($model) : null;
    }
    
    public function findByAuthor(UserId $authorId): array
    {
        return PostModel::where('user_id', $authorId->value())
            ->orderBy('created_at', 'desc')
            ->get()
            ->map(function ($model) {
                return $this->toDomainEntity($model);
            })
            ->all();
    }
    
    public function findPublished(int $limit = 10, int $offset = 0): array
    {
        return PostModel::where('status', 'published')
            ->orderBy('published_at', 'desc')
            ->limit($limit)
            ->offset($offset)
            ->get()
            ->map(function ($model) {
                return $this->toDomainEntity($model);
            })
            ->all();
    }
    
    public function save(Post $post): void
    {
        $model = PostModel::firstOrNew(['id' => $post->id()->value()]);
        
        $model->id = $post->id()->value();
        $model->title = $post->title()->value();
        $model->content = $post->content()->value();
        $model->user_id = $post->authorId()->value();
        $model->status = $post->status()->value();
        $model->published_at = $post->publishedAt();
        
        // 新規作成の場合のみ作成日時を設定
        if (!$model->exists) {
            $model->created_at = $post->createdAt();
        }
        
        $model->updated_at = $post->updatedAt();
        
        $model->save();
    }
    
    public function delete(PostId $id): void
    {
        PostModel::destroy($id->value());
    }
    
    /**
     * Eloquentモデルからドメインエンティティへの変換
     */
    private function toDomainEntity(PostModel $model): Post
    {
        // ファクトリメソッドを使用してエンティティを復元
        return Post::reconstruct(
            new PostId($model->id),
            new PostTitle($model->title),
            new PostContent($model->content),
            new UserId($model->user_id),
            PostStatus::fromString($model->status),
            $model->published_at ? new \DateTimeImmutable($model->published_at) : null,
            new \DateTimeImmutable($model->created_at),
            new \DateTimeImmutable($model->updated_at)
        );
    }
}

3. サービスプロバイダーでの依存性注入設定

namespace App\Providers;

use App\Domain\Post\Repositories\PostRepositoryInterface;
use App\Infrastructure\Repositories\EloquentPostRepository;
use Illuminate\Support\ServiceProvider;

class DomainServiceProvider extends ServiceProvider
{
    /**
     * 依存性注入の設定
     */
    public function register(): void
    {
        // リポジトリの依存性注入
        $this->app->bind(
            PostRepositoryInterface::class,
            EloquentPostRepository::class
        );
        
        // アプリケーションサービスの登録
        $this->app->singleton(PostApplicationService::class);
    }
    
    /**
     * サービスの起動処理
     */
    public function boot(): void
    {
        // 必要に応じて設定
    }
}

ベストプラクティス

1. ドメインオブジェクトの設計指針

エンティティ設計のポイント

良い例:

class Post
{
    // プライベートプロパティで不変性を確保
    private PostStatus $status;
    
    // ビジネスルールを含むメソッド
    public function publish(): void
    {
        if (!$this->canPublish()) {
            throw new PostCannotBePublishedException();
        }
        // 状態変更処理
    }
    
    // ビジネスルールの判定
    private function canPublish(): bool
    {
        return !$this->title->isEmpty() && !$this->content->isEmpty();
    }
}

悪い例:

class Post
{
    // パブリックプロパティ(不変性が保てない)
    public $status;
    
    // セッターメソッド(ビジネスルールを迂回できる)
    public function setStatus($status): void
    {
        $this->status = $status;
    }
}

値オブジェクト設計のポイント

/**
 * 優れた値オブジェクトの特徴:
 * 1. 不変性
 * 2. 自己検証
 * 3. 意味のある操作
 * 4. 等価性比較
 */
final class Money
{
    private int $amount;
    private Currency $currency;
    
    public function __construct(int $amount, Currency $currency)
    {
        if ($amount < 0) {
            throw new \InvalidArgumentException('金額は0以上である必要があります');
        }
        
        $this->amount = $amount;
        $this->currency = $currency;
    }
    
    /**
     * 意味のある操作:加算
     */
    public function add(Money $other): Money
    {
        if (!$this->currency->equals($other->currency)) {
            throw new \InvalidArgumentException('異なる通貨同士は計算できません');
        }
        
        return new Money($this->amount + $other->amount, $this->currency);
    }
    
    /**
     * 等価性比較
     */
    public function equals(Money $other): bool
    {
        return $this->amount === $other->amount 
            && $this->currency->equals($other->currency);
    }
}

2. レイヤー間の依存関係管理

依存関係の原則:

  • ドメイン層は他の層に依存しない
  • アプリケーション層はドメイン層のみに依存
  • インフラ層はドメインのインターフェースに依存
  • プレゼンテーション層はアプリケーション層に依存

3. テスト戦略

ドメインエンティティのテスト

namespace Tests\Unit\Domain\Post\Entities;

use App\Domain\Post\Entities\Post;
use App\Domain\Post\ValueObjects\{PostId, PostTitle, PostContent};
use App\Domain\User\ValueObjects\UserId;
use PHPUnit\Framework\TestCase;

class PostTest extends TestCase
{
    public function test_新規投稿は下書き状態で作成される(): void
    {
        $post = new Post(
            new PostId('test-id'),
            new PostTitle('テストタイトル'),
            new PostContent('テスト本文'),
            new UserId('author-id')
        );
        
        $this->assertTrue($post->isDraft());
        $this->assertFalse($post->isPublished());
    }
    
    public function test_タイトルと本文がある場合公開できる(): void
    {
        $post = new Post(
            new PostId('test-id'),
            new PostTitle('テストタイトル'),
            new PostContent('テスト本文'),
            new UserId('author-id')
        );
        
        $this->assertTrue($post->canPublish());
        
        $post->publish();
        
        $this->assertTrue($post->isPublished());
        $this->assertNotNull($post->publishedAt());
    }
    
    public function test_タイトルが空の場合公開できない(): void
    {
        $this->expectException(PostCannotBePublishedException::class);
        
        $post = new Post(
            new PostId('test-id'),
            new PostTitle(''), // 空のタイトル(実際はValueObjectでエラーになる)
            new PostContent('テスト本文'),
            new UserId('author-id')
        );
        
        $post->publish();
    }
}

アプリケーションサービスのテスト

namespace Tests\Unit\Application\Services;

use App\Application\Services\PostApplicationService;
use App\Domain\Post\Repositories\PostRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Mockery;

class PostApplicationServiceTest extends TestCase
{
    private PostRepositoryInterface $mockRepository;
    private PostApplicationService $service;
    
    protected function setUp(): void
    {
        parent::setUp();
        
        $this->mockRepository = Mockery::mock(PostRepositoryInterface::class);
        $this->service = new PostApplicationService($this->mockRepository);
    }
    
    public function test_投稿作成が正常に実行される(): void
    {
        // モックの設定
        $this->mockRepository
            ->shouldReceive('nextId')
            ->once()
            ->andReturn(new PostId('new-id'));
            
        $this->mockRepository
            ->shouldReceive('save')
            ->once();
        
        // テスト実行
        $request = new CreatePostRequest(
            title: 'テストタイトル',
            content: 'テスト本文',
            authorId: 'author-id',
            shouldPublish: false
        );
        
        $response = $this->service->createPost($request);
        
        // 結果検証
        $this->assertEquals('テストタイトル', $response->post->title()->value());
        $this->assertTrue($response->post->isDraft());
    }
}

4. パフォーマンス最適化

N+1問題の回避

class EloquentPostRepository implements PostRepositoryInterface
{
    public function findPublishedWithAuthors(int $limit = 10): array
    {
        // Eager LoadingでN+1問題を回避
        return PostModel::with('user')
            ->where('status', 'published')
            ->orderBy('published_at', 'desc')
            ->limit($limit)
            ->get()
            ->map(function ($model) {
                return $this->toDomainEntityWithAuthor($model);
            })
            ->all();
    }
    
    private function toDomainEntityWithAuthor(PostModel $model): Post
    {
        // Authorエンティティも同時に構築
        $author = new Author(
            new UserId($model->user->id),
            new UserName($model->user->name),
            new Email($model->user->email)
        );
        
        return Post::reconstructWithAuthor(
            // ... Post構築
            $author
        );
    }
}

キャッシュの活用

class CachedPostRepository implements PostRepositoryInterface
{
    private PostRepositoryInterface $repository;
    private CacheManager $cache;
    
    public function findById(PostId $id): ?Post
    {
        $cacheKey = "post:{$id->value()}";
        
        return $this->cache->remember($cacheKey, 3600, function() use ($id) {
            return $this->repository->findById($id);
        });
    }
    
    public function save(Post $post): void
    {
        $this->repository->save($post);
        
        // キャッシュの無効化
        $this->cache->forget("post:{$post->id()->value()}");
    }
}

よくある質問と回答

Q1: EloquentモデルとドメインエンティティでのDATA重複は問題ないのか?

A1: この重複は意図的で、以下の利点があります:

利点:

  • 関心の分離: それぞれが異なる責任を持つ
  • テスタビリティ: ドメインロジックをDB無しでテスト可能
  • 柔軟性: データベース構造変更がドメインロジックに影響しない
  • 保守性: ビジネスロジックが明確に分離される

Q2: 値オブジェクトのデータベース保存方法

A2: 主要な戦略は以下の通りです:

戦略1: 分解保存(推奨)

// 住所値オブジェクト
class Address
{
    private string $street;
    private string $city;
    private string $zipCode;
}

// マイグレーション
Schema::create('users', function (Blueprint $table) {
    $table->string('address_street');
    $table->string('address_city');
    $table->string('address_zip_code');
});

// リポジトリでの変換
private function toDomainEntity(UserModel $model): User
{
    $address = new Address(
        $model->address_street,
        $model->address_city,
        $model->address_zip_code
    );
    
    return new User($model->id, $model->name, $address);
}

戦略2: JSON保存

// マイグレーション
Schema::create('users', function (Blueprint $table) {
    $table->json('address');
});

// Eloquentモデル
class UserModel extends Model
{
    protected $casts = [
        'address' => 'array'
    ];
}

// リポジトリでの変換
private function save(User $user): void
{
    $model->address = [
        'street' => $user->address()->street(),
        'city' => $user->address()->city(),
        'zip_code' => $user->address()->zipCode(),
    ];
}

Q3: いつDDDを採用すべきか?

A3: プロジェクト複雑度により判断します:

DDD採用の指標:

採用すべき場合:

  • 複雑なビジネスルールが多数存在
  • ドメインエキスパートとの密な連携が必要
  • 長期的な保守性が重要
  • チーム規模が大きい
  • ビジネスルールが頻繁に変更される

不要な場合:

  • シンプルなCRUD操作が中心
  • 短期プロジェクト
  • 小規模チーム
  • 一人開発
  • プロトタイプ開発

Q4: DDDでのトランザクション管理方法

A4: アプリケーションサービス層で管理します:

class PostApplicationService
{
    public function publishPostWithNotification(PublishPostRequest $request): PublishPostResponse
    {
        return DB::transaction(function() use ($request) {
            // 1. 投稿を取得
            $post = $this->postRepository->findById(new PostId($request->postId));
            
            // 2. ビジネスロジック実行
            $post->publish();
            
            // 3. 投稿を保存
            $this->postRepository->save($post);
            
            // 4. 通知送信(同一トランザクション内)
            $this->notificationService->sendPublishNotification($post);
            
            return new PublishPostResponse($post);
        });
    }
}

Q5: ドメインイベントの実装方法

A5: イベント駆動アーキテクチャと組み合わせます:

// ドメインイベント
class PostPublishedEvent
{
    public function __construct(
        private Post $post,
        private \DateTimeImmutable $occurredAt
    ) {}
    
    public function post(): Post { return $this->post; }
    public function occurredAt(): \DateTimeImmutable { return $this->occurredAt; }
}

// エンティティ内でイベント発行
class Post
{
    public function publish(): void
    {
        // ... ビジネスロジック
        
        // イベント発行
        DomainEvents::raise(new PostPublishedEvent($this, new \DateTimeImmutable()));
    }
}

// イベントハンドラー
class SendNotificationWhenPostPublished
{
    public function handle(PostPublishedEvent $event): void
    {
        // メール通知、Slack通知など
        $this->notificationService->notify($event->post());
    }
}

まとめ

DDDの核心的価値

ドメイン駆動設計は単なる技術的パターンではなく、ビジネス価値を最大化するための設計思想です。以下の価値を提供します:

段階的導入のロードマップ

DDDを既存Laravelプロジェクトに導入する際の推奨アプローチ:

フェーズ1: 値オブジェクト導入(1-2週間)

// Before
class User extends Model
{
    public function setEmailAttribute($value)
    {
        $this->attributes['email'] = strtolower($value);
    }
}

// After
class Email // Value Object
{
    public function __construct(string $value)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException();
        }
        $this->value = strtolower($value);
    }
}

フェーズ2: サービス層分離(2-3週間)

// Before: Controller内にビジネスロジック
public function store(Request $request)
{
    $user = new User($request->all());
    $user->save();
    Mail::to($user)->send(new WelcomeMail());
}

// After: アプリケーションサービス
public function store(Request $request)
{
    $this->userService->createUser($request->validated());
}

フェーズ3: エンティティとリポジトリ(3-4週間)
完全なドメインモデルの構築と依存関係の逆転

成功のための重要なポイント

  1. チーム全体の理解: DDD は個人技術ではなくチーム活動
  2. 段階的導入: 一度にすべてを変更せず、小さく始める
  3. ドメインエキスパートとの協働: 技術者だけで進めない
  4. 継続的リファクタリング: 理解が深まるにつれてモデルを改善
  5. 過度な抽象化を避ける: シンプルさを保つ

最後に

DDDは「技術のための技術」ではなく、より良いソフトウェアを通じてビジネス価値を提供するための手段です。Laravel の柔軟性を活かし、プロジェクトの成熟度に応じて適切なレベルでDDDの概念を取り入れることで、保守性と表現力に優れたアプリケーションを構築できます。

重要なのは、DDDの技術的パターンを機械的に適用することではなく、ドメインの本質を理解し、コードでそれを表現することです。この視点を持って取り組むことで、真にビジネス価値を生み出すソフトウェア開発が可能になります。

Discussion