Todoアプリで学ぶドメイン駆動設計入門

プログラミング

こんにちは!

今回は具体的なWebアプリケーションを作りながら、ドメイン駆動設計を学んでみようと思います!

何かとよく耳にするドメイン駆動設計ですが、この話は概念的な説明が多くなりがちで、具体的なソースコードが登場することはあまりないかと思います。
それもそのはずで、ドメイン駆動設計は一つの設計の考え方みたいなもので、ふわっとしてます。
そのため、具体的にこう書くのが正解!みたいなものがありません。

そこで、今回はドメイン駆動設計の考え方にできるだけ沿って、Todoアプリケーションを実装していきたいと思います!
ドメイン駆動設計について学んでみたい方には参考になるのではないかと思います。

それでは、さっそく見ていきましょう!

ドメイン駆動設計とは

そもそもドメイン駆動設計って何だ?と思われる方もいるかと思いますので、少し説明します。

「ドメイン」というのは、ソフトウェアを使って問題解決しようとしている業務領域のことです。

例えば、今回の制作目標であるTodoアプリケーションであれば、「タスク管理」に関するビジネスルールや要件がドメインに該当します。

そして、この「ドメイン」を中心にシステムを設計しましょう、と言っているのがドメイン駆動設計です。

そもそもシステムが何のためにあるのか、と考えてみると、業務における具体的な問題を解決するためですよね。なので、この「ドメイン」を中心に設計しようという考え方は非常に理にかなっているのではないでしょうか。

Todoアプリにおけるドメインとは

ドメイン駆動設計では、「ドメイン」を正しく把握することが何よりも重要です。

なので、まだソースコードの説明には入りません。

Todoアプリケーションにおける「ドメイン」とは何なのか、についてもう少し深ぼっていきます。

核となる概念

Todoアプリケーションにおける核となる概念を考えてみましょう。
すると、次のようなものが挙げられます。

  • Todo(タスク)・・・タスク管理の基本単位。以下のようなプロパティが含まれる
    • タイトル・・・何をすべきかを表現する
    • ステータス・・・タスクの状態(例: “未完了”, “完了済み”)

ビジネスルール

それでは、次にTodoアプリケーションが持つビジネスルールについて考えてみましょう。

  • タイトルのルール・・・タイトルは空であってはならず、最大255文字まで
  • ステータスの制約・・・ステータスは”未完了”,”進行中”,”完了済み”のいずれかに限定

こんなところでしょうか。

ユースケース

ユースケースはTodoアプリケーションを使うユーザーの具体的な操作を表します。そのため、ユースケースは以下のような感じでしょうか。

  • タスクの作成
  • タスクの編集
  • ステータスの変更
  • タスクの削除

Todoアプリを設計してみる

Todoアプリケーションのビジネスルールや要件が決まったところで、実際に設計してみます。そのために、ドメイン駆動設計でドメインを表現するために出てくる2つの登場人物をご紹介します。

エンティティ

エンティティとは、特定の識別子(IDなど)で区別されるオブジェクトのことです。今回の場合は、「Todo」です。

主に以下のような属性や振る舞いを持ちます。

  • 属性
    • id(識別子)
    • title(タイトル)
    • status(ステータス)
  • 振る舞い
    • タスクを作成する
    • タイトルを更新する
    • タスクを削除する
    • ステータスを変更する

値オブジェクト

値オブジェクトは、システムが持っている値をオブジェクト化したものです。その値に関する振る舞いなども含めて持ちます。

今回の場合は以下のようなところでしょうか。

  • TodoTitle・・・Todoタイトルを表現。タイトルの制約なども振る舞いとして含まれている(255文字以内など)
  • TodoStatus・・・Todoステータスを表現。”未完了”や”完了済み”などの限定された値を表現する

Todoアプリを実装してみる

それでは、いよいよ実装していきます。

ディレクトリ構成は以下のようにします。(ある程度自由です)

app/
|-- Domain/
|   |-- Entities/
|       |-- Todo.php
|   |-- ValueObjects/
|       |-- TodoTitle.php
|       |-- TodoStatus.php
|       |-- DueDate.php
|   |-- Repositories/
|       |-- TodoRepositoryInterface.php
|-- Infrastructure/
|   |-- Repositories/
|       |-- TodoRepository.php 
|-- Application/
|   |-- Services
|       |-- TodoService.php
|-- Http/
|   |-- Controllers/
|       |-- TodoController.php

まずは、ドメインを表現したエンティティと値オブジェクトを書いていきます。

Todo.php

<?php

namespace App\Domain\Entities;

use App\Domain\ValueObjects\TodoTitle;
use App\Domain\ValueObjects\TodoStatus;

class Todo
{
    private int $id;
    private TodoTitle $title;
    private TodoStatus $status;

    public function __construct(int $id, TodoTitle $title, TodoStatus $status)
    {
        $this->id = $id;
        $this->title = $title;
        $this->status = $status;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getTitle(): string
    {
        return $this->title->getValue();
    }

    public function getStatus(): string
    {
        return $this->status->getValue();
    }

    public function changeTitle(TodoTitle $title): void
    {
        $this->title = $title;
    }

    public function markAsCompleted(): void
    {
        $this->status = TodoStatus::completed();
    }
}

TodoTitle.php

<?php

namespace App\Domain\ValueObjects;

class TodoTitle
{
    private string $value;

    public function __construct(string $value)
    {
        if (strlen($value) > 255) {
            throw new \InvalidArgumentException('Title is too long');
        }

        $this->value = $value;
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

    public function getValue(): string
    {
        return $this->value;
    }
}

TodoStatus.php

<?php

namespace App\Domain\ValueObjects;

class TodoStatus
{
    private const STATUS_PENDING = 'pending';
    private const STATUS_COMPLETED = 'completed';

    private string $value;

    private function __construct(string $value)
    {
        $this->value = $value;
    }

    public static function fromString(string $value): self
    {
        return new self($value);
    }

    public static function pending(): self
    {
        return new self(self::STATUS_PENDING);
    }

    public static function completed(): self
    {
        return new self(self::STATUS_COMPLETED);
    }

    public function getValue(): string
    {
        return $this->value;
    }
}

次に、リポジトリインターフェースを実装していきます。リポジトリインターフェースは、外部に(ここではDB)エンティティを永続化したい場合や更新を行う際のメソッド名だけ書いておきます。

なぜ直接DBに更新をかける形ではなく、インターフェースを使うかというと、ドメインモデルが外部要素であるDBに依存してしまうのを避けるためです。

ドメイン駆動設計では、ドメインモデル(エンティティなど)を最終依存先にすることが重要です。こうすることで、DBやUI、フレームワークなどの外部要素に依存せず、取り替えが可能になるからです。

TodoRepositoryInterface.php

<?php

namespace App\Domain\Repositories;

use App\Domain\Entities\Todo;
use Illuminate\Support\Collection;

interface TodoRepositoryInterface
{
    public function save(Todo $todo): void;
    public function findById(int $id): ?Todo;
    public function findAll(): Collection;
    public function generateId(): int;
    public function update(Todo $todo): void;
    public function delete(int $id): void;
}

次にアプリケーション層の実装に進みます。

TodoService.php

<?php

namespace App\Application\Services;

use App\Domain\Entities\Todo;
use App\Domain\Repositories\TodoRepositoryInterface;
use App\Domain\ValueObjects\TodoTitle;
use App\Domain\ValueObjects\TodoStatus;
use Illuminate\Support\Collection;

class TodoService
{
    private TodoRepositoryInterface $repository;

    public function __construct(TodoRepositoryInterface $repository)
    {
        $this->repository = $repository;
    }

    public function createTodo(string $title): Todo
    {
        $id = $this->repository->generateId();
        $todo = new Todo($id, new TodoTitle($title), TodoStatus::pending());
        $this->repository->save($todo);
        return $todo;
    }

    public function completeTodo(int $id): void
    {
        $todo = $this->repository->findById($id);
        if (empty($todo)) throw new \Exception("Todo not found");
        $todo->markAsCompleted();
        $this->repository->update($todo);
    }

    public function getTodos(): Collection
    {
        return $this->repository->findAll();
    }
    
    public function changeTitle(int $id, string $title): void
    {
        $todo = $this->repository->findById($id);
        if (empty($todo)) throw new \Exception("Todo not found");
        $todo->changeTitle(new TodoTitle($title));
        $this->repository->update($todo);
    }

    public function delete(int $id): void
    {
        $this->repository->delete($id);
    }
}

インフラ層は次のようになります。

TodoRepository.php

<?php

namespace App\Infrastructure\Repositories;

use App\Domain\Entities\Todo;
use App\Domain\ValueObjects\TodoStatus;
use App\Domain\ValueObjects\TodoTitle;
use App\Domain\Repositories\TodoRepositoryInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;

class TodoRepository implements TodoRepositoryInterface
{
    public function save(Todo $todo): void
    {
        DB::table('todos')->insert([
            'title' => $todo->getTitle(),
            'status' => $todo->getStatus(),
        ]);
    }

    public function update(Todo $todo): void
    {
        DB::table('todos')->where('id', $todo->getId())->update([
            'title' => $todo->getTitle(),
            'status' => $todo->getStatus(),
        ]);
    }

    public function findById(int $id): ?Todo
    {
        $todo = DB::table('todos')->where('id', $id)->first();
        if (empty($todo)) return null;
        return new Todo(
            $todo->id,
            TodoTitle::fromString($todo->title),
            TodoStatus::fromString($todo->status)
        );
    }

    public function findAll(): Collection
    {
        $todos = DB::table('todos')->get();
        return $todos->map(function ($todo) {
            return new Todo(
                $todo->id,
                TodoTitle::fromString($todo->title),
                TodoStatus::fromString($todo->status)
            );
        });
    }

    public function generateId(): int
    {
        return DB::table('todos')->max('id') + 1;
    }
    
    public function delete(int $id): void
    {
        DB::table('todos')->where('id', $id)->delete();
    }
}

注意点として、インフラ層ではTodoRepositoryInterfaceで定義したメソッドはすべて記述するようにしてください。そうしないと、エラーが発生します。(詳しくはこちら

最後にコントローラです。

TodoController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Application\Services\TodoService;

class TodoController extends Controller
{
    private TodoService $service;

    public function __construct(TodoService $service)
    {
        $this->service = $service;
    }
    
    public function index()
    {
        $todos = $this->service->getTodos();
        return view('todo', compact('todos'));
    }

    public function create(Request $request)
    {
        $title = $request->input('title');
        $this->service->createTodo($title);
        return redirect()->route('index');
    }

    public function complete(Request $request, int $id)
    {
        $this->service->completeTodo($id);
        return redirect()->route('index');
    }
    
    public function changeTitle(Request $request, int $id)
    {
        $title = $request->input('title');
        $this->service->changeTitle($id, $title);
        return redirect()->route('index');
    }

    public function delete(int $id)
    {
        $this->service->delete($id);
        return redirect()->route('index');
    }
}

今回はUI層は特に書きません。

これで実装は以上です。

まとめ

それでは、今回の内容をまとめます。

  • ドメイン駆動設計とは、「ドメイン」を中心にしてソフトウェアを設計する手法でした
  • そのため、ドメイン駆動設計では、まず「ドメイン」を正しく把握する作業が重要でした
  • ドメイン駆動設計では、ドメインをもとにドメインモデルに落とし込んで実装していきました
  • ドメイン駆動設計では、ドメインモデルを最終依存先にする必要がありました

今回制作したTodoアプリケーションの入り口となる、コントローラの実装を確認してみてください。

非常にスッキリしていないでしょうか。

従来のLaravelプロジェクトのMVCに従って実装を行うと、コントローラにビジネスロジックを書き連ねていく形になるため、ごちゃごちゃしがちです。

ドメイン駆動設計をもとに実装されたコードは、ソースコードがそのまま仕様書として読めるぐらい可読性が高く、保守も容易になります。

今回のTodoアプリケーションは構造があまりにも単純なため、わざわざドメイン駆動設計を採用するメリットはあまりないです。しかし、複雑な業務ルールを持つシステムの場合は、ドメイン駆動設計の真価が発揮されます。

最後に注意点として、今回はデモ的にTodoアプリを制作してきたため、本来必要となるバリデーション処理やトランザクションなどは意図的に含んでいません。実際の開発業務では必須で必要となるため、ご注意ください。

この機会にぜひドメイン駆動設計をより深く学習し、設計手法の幅を広げてみてください!

ではでは😄

コメント

タイトルとURLをコピーしました