🔧

とにかくドメイン駆動設計を実践してみる試み ~TODO管理システム編~

2022/02/06に公開約24,200字

はじめに

この記事はサービスを爆速で作ったり、ドメイン駆動設計の解説をするようなものではありません。

ドメイン駆動設計の勉強をしていて、手を動かす機会が足りないと感じていました。そこで、今の理解で実際に動くシステムをドメイン駆動で開発してみようと思いました。

本記事はその開発の過程や考えていたことを記録したものです。
「この人はこういう形に落とし込んだんだな~」くらいで見ていただけたらありがたいです。

作成するシステム

今回作るのはTODO管理システムです。
初回の開発では以下の機能を開発しました。

  • TODOのタイトルと詳細を登録できる
  • 作成したTODOを検索できる
  • 選択したTODOの詳細を確認、完了、削除ができる

デモ

デモなのでメールの確認はダミーです。新規登録をしたら画面に出るメール確認リンクを踏めば確認済みとなります。
その後右上のログインからログインしてください。
デモのデータベースはインメモリで動いているのでアプリを終了すると作成したデータもすべて消えます。

Docker で動かす場合

  1. GitHubからプロジェクトをクローン
  2. クローンしたプロジェクト内のsrcフォルダに移動
  3. 以下のコマンドを実行
> docker build -t tatsuteb/todo:demo .
> docker run -d --name tatsuteb-todo -p 80:80 -d tatsuteb/todo:demo
  1. ブラウザで http://localhost へアクセス
  2. 停止する場合は以下のコマンドを実行
> docker stop tatsuteb-todo
  1. コンテナを削除してクリーンアップする場合は以下のコマンドを実行
> docker rm tatsuteb-todo

.NET Core SDK で動かす場合

  1. GitHubからプロジェクトをクローン
  2. クローンしたプロジェクト内のsrcフォルダに移動
  3. 以下のコマンドを実行
> dotnet run --project ./WebClient
  1. ブラウザで http://localhost:5000 へアクセス
  2. 停止する場合は Ctrl+C

モデリング

システム図

開発するシステムの範囲を定義します。
図の枠線内が今回の開発するシステムとなります。

ユースケース図

システムのふるまいを次のように定義します。
破線内が今回開発する範囲です。
この図には書いていませんが、ユーザーの認証・認可は.NET Core Identityに委譲します。

オブジェクト図

ドメインモデル図を作成する前に、実際に入るデータやオブジェクト同士の関係をオブジェクト図で表現してみます。

ドメインモデル図

オブジェクト図をもとにドメインモデル図を作成してみました。
ドメインモデルをつなぐ「―◆」はインスタンス参照、「→」はID参照をあらわしています。「1」や「0..n」は多重度を表しています。
破線の四角い吹き出しにはドメインが守るべきルールを記入しています。
永続化はリポジトリを使って集約単位で行います。

実装

ASP.NET Core 6.0 で実装していきます。
一応、一部のソースコードも載せています。動くコードが気になる人はGitHubをご参照ください。

プロジェクトの構成

アプリケーションアーキテクチャはオニオンアーキテクチャを採用しています。
構成は以下のようになっています。

ソリューション構成は下図のようになっています。
WebClientはブラウザ向けのMVCクライアントで上図のプレゼンテーション層に相当します。
今はフロントエンドをRazorビューで作っていますが、Reactで作り直す予定です。

値オブジェクト

ライフサイクルを持たず不変な値オブジェクトはいろいろな場面で作成するので、比較可能な抽象クラスValueObjectを用意して、これを継承するようにします。
ただ、ユーザ名やTODOタイトルのようにプロパティを一つしか持たないものが多かったので、Valueプロパティを持つSingleValueObjectを用意しました。このクラスはValueObjectを継承しています。「住所」のように複数のプロパティをまとめて値オブジェクトとしたい場合はValueObjectを直接継承して使う想定です。

ValueObjectとSingleValueObject
ValueObject.cs
namespace Domain.Models.Shared;

public abstract class ValueObject
{
    protected abstract IEnumerable<object> GetEqualityComponents();

    public bool Equals(ValueObject? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;

        return GetEqualityComponents()
            .SequenceEqual(other.GetEqualityComponents());
    }

    public override bool Equals(object? obj)
    {
        if (obj is null) return false;
        if (ReferenceEquals(this, obj)) return true;

        return obj.GetType() == GetType() && Equals((ValueObject)obj);
    }

    public override int GetHashCode()
    {
        return GetEqualityComponents()
            .Select(x => x.GetHashCode())
            .Aggregate(HashCode.Combine);
    }

    public static bool operator ==(ValueObject? left, ValueObject? right)
    {
        return Equals(left, right);
    }

    public static bool operator !=(ValueObject? left, ValueObject? right)
    {
        return !Equals(left, right);
    }
}
SingleValueObject.cs
namespace Domain.Models.Shared
{
    public abstract class SingleValueObject<T> : ValueObject
    {
        public T Value { get; }

        protected SingleValueObject(T value)
        {
            Value = value ?? throw new ArgumentNullException(nameof(value), "値を設定してください。");
        }

        protected override IEnumerable<object> GetEqualityComponents()
        {
            if (Value is null)
            {
                throw new ArgumentNullException(nameof(Value), "値を設定してください。");
            }

            yield return Value;
        }
    }
}

例外処理

ドメイン層とユースケース層で発生した例外はそれぞれDomainExceptionUseCaseExceptionとしてまとめて投げています。この例外にはユーザーに向けて表示する前提でエラーメッセージを設定しています。
投げられたエラーはプレゼンテーション層のWebClientで定義しているWebClientExceptionFilterで受け取ってフロントエンドに返します。DomainExceptionでもUseCaseExceptionでもないエラーは想定外のエラーということでILoggerでログにも入れています。

DomainException、WebClientExceptionFilter
DomainException
namespace Domain.Models.Shared
{
    public class DomainException : Exception
    {
        public int? StatusCode { get; }

        public DomainException(string? message, int? statusCode = null) : base(message)
        {
            StatusCode = statusCode;
        }
    }
}
WebClientExceptionFilter
using Domain.Models.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using UseCase.Shared;

namespace WebClient.Models.Shared.Exceptions
{
    public class WebClientExceptionFilter : IExceptionFilter
    {
        private readonly ILogger<WebClientExceptionFilter> _logger;

        public WebClientExceptionFilter(ILogger<WebClientExceptionFilter> logger)
        {
            _logger = logger;
        }

        public void OnException(ExceptionContext context)
        {
            // 想定外のエラー(デフォルト)
            var value = new WebClientExceptionResponseModel(
                message: "不明なエラーが発生しました");
            var statusCode = StatusCodes.Status500InternalServerError;

            switch (context.Exception)
            {
                case null:
                    return;

                // 想定内のエラー
                case DomainException domainException:
                {
                    value = new WebClientExceptionResponseModel(
                        message: domainException.Message);
                    statusCode = domainException.StatusCode ?? StatusCodes.Status400BadRequest;
                    break;
                }
                case UseCaseException useCaseException:
                {
                    value = new WebClientExceptionResponseModel(
                        message: useCaseException.Message);
                    statusCode = useCaseException.StatusCode ?? StatusCodes.Status400BadRequest;
                    break;
                }
                default:
                {
                    _logger.LogError(context.Exception.Message);
                    break;
                }
            }
            
            context.Result = new ObjectResult(value)
            {
                StatusCode = statusCode
            };
            context.ExceptionHandled = true;
        }
    }
}

認証

認証を前提としたユースケースを呼び出す際は、ユースケース層で定義したUserSessionに認証済みユーザーのIDを詰めて渡すようにしています。他に必要なセッション情報があればUserSessionクラスに追加していきます。
依存の向きを「プレゼンテーション層 → ユースケース層」とするためにUserSessionはユースケース層で定義しています。

UserSession
namespace UseCase.Shared
{
    public class UserSession
    {
        public string Id { get; }

        public UserSession(string id)
        {
            Id = id;
        }
    }
}

テスト

NUnitを使ってテストプロジェクトを作成します。
テストメソッド名は分かりやすさ重視で日本語で書きました。
※ テストの学習が全然追いついていないので見よう見真似で書いています…。

値オブジェクト、エンティティのテスト

値オブジェクト、エンティティ内で担保しているルールの数だけテストケースを書いています。
例えばTodoTitle値オブジェクトのテストは「50文字以下の文字列を渡すとインスタンスが生成される」とか「51文字以上の文字列を渡すと例外が発生する」といったテストを書いてちゃんとドメインルールが守られているかテストしています。

TodoTitleとTodoのテスト
TodoTitleTest.cs
using Domain.Models.Shared;
using Domain.Models.Todos;
using NUnit.Framework;

namespace Test.Domain.Models.Todos
{
    public class TodoTitleTest
    {
        [Test]
        public void 引数に50文字以下の文字列を渡すとインスタンスが生成される()
        {
            const string value = "テスト";

            var title = new TodoTitle(value);

            Assert.That(title.Value, Is.EqualTo(value));
        }

        [Test]
        public void 引数に51文字以上の文字列を渡すと例外が発生する()
        {
            // 51文字
            const string value = "Lorem ipsum dolor sit amet, consectetur erat curae.";

            Assert.That(
                () => new TodoTitle(value),
                Throws.TypeOf<DomainException>());
        }
    }
}
TodoTest.cs
using Domain.Models.Shared;
using Domain.Models.Todos;
using Domain.Models.Users;
using NUnit.Framework;
using System;
using Test.Helpers;

namespace Test.Domain.Models.Todos
{
    public class TodoTest
    {
        #region CreateNew, CreateFromRepository

        [Test]
        public void 新しくTODOを作成すると未完了状態でインスタンスが生成される()
        {
            // 準備
            var userId = new UserId(Guid.NewGuid().ToString("D"));
            var title = new TodoTitle("タイトル");
            var description = new TodoDescription("詳細");

            var operationDateTime = DateTime.Now;
            
            // 実行
            var todo = Todo.CreateNew(
                title: title,
                description: description,
                ownerId: userId);

            // 検証
            Assert.That(todo.Title, Is.EqualTo(title));
            Assert.That(todo.Description, Is.EqualTo(description));
            Assert.That(todo.OwnerId, Is.EqualTo(userId));
            Assert.That(todo.CreatedDateTime, Is.InRange(operationDateTime, operationDateTime.AddSeconds(10)));
            Assert.That(todo.UpdatedDateTime, Is.InRange(operationDateTime, operationDateTime.AddSeconds(10)));
            Assert.That(todo.Status, Is.EqualTo(TodoStatus.未完了));
            Assert.That(todo.IsDeleted, Is.False);
            Assert.That(todo.DeletedDateTime, Is.Null);
        }

        [Test]
        public void リポジトリからTODOを作成するとインスタンスが生成される()
        {
            // 準備
            var todoId = TodoId.Generate();
            var userId = new UserId(Guid.NewGuid().ToString("D"));
            var title = new TodoTitle("タイトル");
            var description = new TodoDescription("詳細");

            var createdDateTime = DateTime.Now;
            var updatedDateTime = DateTime.Now.AddDays(1);

            // 実行
            var todo = Todo.CreateFromRepository(
                id: todoId,
                title: title,
                description: description,
                ownerId: userId,
                createdDateTime: createdDateTime,
                updatedDateTime: updatedDateTime,
                status: TodoStatus.完了,
                isDeleted: false,
                deletedDateTime: null);

            // 検証
            Assert.That(todo.Title, Is.EqualTo(title));
            Assert.That(todo.Description, Is.EqualTo(description));
            Assert.That(todo.OwnerId, Is.EqualTo(userId));
            Assert.That(todo.CreatedDateTime, Is.EqualTo(createdDateTime));
            Assert.That(todo.UpdatedDateTime, Is.EqualTo(updatedDateTime));
            Assert.That(todo.Status, Is.EqualTo(TodoStatus.完了));
            Assert.That(todo.IsDeleted, Is.False);
            Assert.That(todo.DeletedDateTime, Is.Null);
        }

        #endregion

        #region UpdateStatus

        [Test]
        public void 引数にステータスを渡すとステータスが更新される()
        {
            // 準備
            var todo = TodoGenerator.Generate(
                status: TodoStatus.未完了,
                isDeleted: false);

            // 実行
            todo.UpdateStatus(TodoStatus.完了);

            // 検証
            Assert.That(todo.Status, Is.EqualTo(TodoStatus.完了));
        }

        [Test]
        public void 削除済みのTODOのステータスを更新すると例外が発生する()
        {
            // 準備
            var todo = TodoGenerator.Generate(
                isDeleted: true,
                deletedDateTime: DateTime.Now);

            // 実行・検証
            Assert.That(
                () => todo.UpdateStatus(TodoStatus.完了),
                Throws.TypeOf<DomainException>());
        }

        #endregion

        #region Delete

        [Test]
        public void TODOを削除すると削除フラグがたち削除した日付が入る()
        {
            // 準備
            var todo = TodoGenerator.Generate(
                isDeleted: false,
                deletedDateTime: null);

            // 実行
            todo.Delete();

            // 検証
            Assert.That(todo.IsDeleted, Is.True);
            Assert.That(todo.DeletedDateTime, Is.InRange(DateTime.Now.AddSeconds(-10), DateTime.Now.AddSeconds(10)));
        }

        #endregion

        #region Edit

        [Test]
        public void 引数にタイトルと詳細を渡して編集する()
        {
            // 準備
            var todo = TodoGenerator.Generate(
                title: "タイトル",
                description: "説明文");
            var newTitle = new TodoTitle("新しいタイトル");
            var newDescription = new TodoDescription("新しい説明文");

            // 実行
            todo.Edit(newTitle, newDescription);

            // 検証
            Assert.That(todo.Title, Is.EqualTo(newTitle));
            Assert.That(todo.Description, Is.EqualTo(newDescription));
        }

        [Test]
        public void 削除済みのTODOを編集しようとすると例外が発生する()
        {
            // 準備
            var todo = TodoGenerator.Generate(
                title: "タイトル",
                description: "説明文",
                isDeleted: true);
            var newTitle = new TodoTitle("新しいタイトル");
            var newDescription = new TodoDescription("新しい説明文");

            // 実行・検証
            Assert.That(
                () => todo.Edit(newTitle, newDescription),
                Throws.TypeOf<DomainException>());
        }

        #endregion
    }
}

テストを楽にするためのヘルパー

ユースケース、リポジトリのテストに入る前によく使うエンティティを生成するジェネレータを作成しました。このジェネレーターを使ってユースケース、リポジトリのテストで前もって生成しておきたいオブジェクトを指定の条件で作成します。

Todoジェネレータ
TodoGenerator.cs
using Domain.Models.Todos;
using System;
using Domain.Models.Users;

namespace Test.Helpers
{
    internal class TodoGenerator
    {
        public static Todo Generate(
            string? id = null,
            string? title = null,
            string? description = null,
            string? ownerId = null,
            DateTime? createdDateTime = null,
            DateTime? updatedDateTime = null,
            TodoStatus status = TodoStatus.未完了,
            bool isDeleted = false,
            DateTime? deletedDateTime = null)
        {
            return Todo.CreateFromRepository(
                id: id is null ? TodoId.Generate() : new TodoId(id),
                title: title is null ? new TodoTitle("タイトル") : new TodoTitle(title),
                description: description is null ? new TodoDescription("詳細") : new TodoDescription(description),
                ownerId: ownerId is null ? new UserId(Guid.NewGuid().ToString("D")) : new UserId(ownerId),
                createdDateTime: createdDateTime ?? DateTime.Now,
                updatedDateTime: updatedDateTime ?? DateTime.Now,
                status: status,
                isDeleted: isDeleted,
                deletedDateTime: isDeleted
                    ? deletedDateTime ?? DateTime.Now
                    : null);
        }
    }
}

リポジトリ、クエリサービスのテスト

テストでは実際のDBに書き込まずにテスト用にインメモリで使えるDBを使っています。
このテスト用のDBコンテキストを持つ抽象クラスUseDbContextTestBaseを用意しました。DBへの読み書きを伴うテストはこのクラスと継承して作っています。
例えばTodoRepositoryでは「引数にTODOモデルを渡すとDBに保存される」、「引数にTODOのIDを渡すとDBからTODOを生成する」といったことをテストしています。

UseDbContextTestBase、TodoRepositoryのテスト
UseDbContextTestBase.cs
using System.Threading.Tasks;
using Infrastructure.DataModels;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;

namespace Test.Shared
{
    public abstract class UseDbContextTestBase
    {
        protected AppDbContext TestDbContext;

        protected UseDbContextTestBase()
        {
            TestDbContext = new AppDbContext(
                options: new DbContextOptionsBuilder<AppDbContext>()
                    .UseInMemoryDatabase("todo_management_system_test_db")
                    .Options);

            TestDbContext.Database.EnsureCreated();
        }

        [SetUp]
        public virtual async Task SetupAsync()
        {
            // Users
            TestDbContext.Users.RemoveRange(TestDbContext.Users);
            TestDbContext.UserProfiles.RemoveRange(TestDbContext.UserProfiles);
            // Todos
            TestDbContext.Todos.RemoveRange(TestDbContext.Todos);

            await TestDbContext.SaveChangesAsync();
        }
    }
}
TodoRepository.cs
using Domain.Models.Todos;
using Infrastructure.Todos;
using NUnit.Framework;
using System.Threading.Tasks;
using Test.Helpers;
using Test.Shared;

namespace Test.Infrastructure.Todos
{
    public class TodoRepositoryTest : UseDbContextTestBase
    {
        private readonly ITodoRepository _todoRepository;

        public TodoRepositoryTest()
        {
            _todoRepository = new TodoRepository(TestDbContext);
        }

        [Test]
        public async Task 引数にTODOモデルを渡すとDBに保存される()
        {
            // 準備
            var todo = TodoGenerator.Generate();

            // 実行
            await _todoRepository.SaveAsync(todo);

            // 検証
            var todoDataModel = await TestDbContext.Todos
                .FindAsync(todo.Id.Value);
            Assert.That(todoDataModel, Is.Not.Null);
            Assert.That(todoDataModel?.Id, Is.EqualTo(todo.Id.Value));
            Assert.That(todoDataModel?.Title, Is.EqualTo(todo.Title.Value));
            Assert.That(todoDataModel?.Description, Is.EqualTo(todo.Description?.Value));
            Assert.That(todoDataModel?.OwnerId, Is.EqualTo(todo.OwnerId.Value));
            Assert.That(todoDataModel?.Status, Is.EqualTo((int)todo.Status));
            Assert.That(todoDataModel?.CreatedDateTime, Is.EqualTo(todo.CreatedDateTime));
            Assert.That(todoDataModel?.UpdatedDateTime, Is.EqualTo(todo.UpdatedDateTime));
            Assert.That(todoDataModel?.IsDeleted, Is.EqualTo(todo.IsDeleted));
            Assert.That(todoDataModel?.DeletedDateTime, Is.EqualTo(todo.DeletedDateTime));
        }

        [Test]
        public async Task 引数にTODOのIDを渡すとDBからTODOを生成する()
        {
            // 準備
            var todo = TodoGenerator.Generate();
            await _todoRepository.SaveAsync(todo);

            // 実行
            var foundTodo = await _todoRepository.FindAsync(todo.Id);

            // 検証
            Assert.That(foundTodo, Is.Not.Null);
            Assert.That(foundTodo?.Id, Is.EqualTo(todo.Id));
            Assert.That(foundTodo?.Title, Is.EqualTo(todo.Title));
            Assert.That(foundTodo?.Description, Is.EqualTo(todo.Description));
            Assert.That(foundTodo?.OwnerId, Is.EqualTo(todo.OwnerId));
            Assert.That(foundTodo?.Status, Is.EqualTo(todo.Status));
            Assert.That(foundTodo?.CreatedDateTime, Is.EqualTo(todo.CreatedDateTime));
            Assert.That(foundTodo?.UpdatedDateTime, Is.EqualTo(todo.UpdatedDateTime));
            Assert.That(foundTodo?.IsDeleted, Is.EqualTo(todo.IsDeleted));
            Assert.That(foundTodo?.DeletedDateTime, Is.EqualTo(todo.DeletedDateTime));
        }

        [Test]
        public async Task 引数に存在しないTODOのIDを渡すとnullが返る()
        {
            // 準備
            var todo = TodoGenerator.Generate();

            // 実行
            var foundTodo = await _todoRepository.FindAsync(todo.Id);

            // 検証
            Assert.That(foundTodo, Is.Null);
        }
    }
}

ユースケースのテスト

ユースケースで想定している振る舞いをテストします。
例えばTODOを削除するユースケースでは「存在しないTODOを指定して削除すると例外が発生する」、「IDを指定して削除すると削除フラグを立てて保存する」といったことをテストしています。
値オブジェクトやエンティティで担保しているルールはそちらのテストで保障されているのでここではテストしません。

TodoGetUseCaseのテスト
TodoGetUseCaseTest.cs
using System;
using Domain.Models.Todos;
using Infrastructure.Todos;
using NUnit.Framework;
using System.Threading.Tasks;
using Test.Helpers;
using Test.Shared;
using UseCase.Shared;
using UseCase.Todos.Get;

namespace Test.UseCase.Todos
{
    public class TodoGetUseCaseTest : UseDbContextTestBase
    {
        private readonly TodoGetUseCase _todoGetUseCase;
        private readonly ITodoRepository _todoRepository;

        public TodoGetUseCaseTest()
        {
            _todoRepository = new TodoRepository(TestDbContext);
            _todoGetUseCase = new TodoGetUseCase(_todoRepository);
        }


        [Test]
        public async Task TODOのIDを指定して詳細を取得する()
        {
            // 準備
            var todo = TodoGenerator.Generate();
            await _todoRepository.SaveAsync(todo);

            // 実行
            var command = new TodoGetCommand(
                userSession: new UserSession(todo.OwnerId.Value),
                id: todo.Id.Value);
            var result = await _todoGetUseCase.ExecuteAsync(command);

            // 検証
            Assert.That(result.Todo.Id, Is.EqualTo(todo.Id.Value));
            Assert.That(result.Todo.Title, Is.EqualTo(todo.Title.Value));
            Assert.That(result.Todo.Description, Is.EqualTo(todo.Description?.Value));
            Assert.That(result.Todo.CreatedDateTime, Is.EqualTo(todo.CreatedDateTime));
            Assert.That(result.Todo.Status, Is.EqualTo((int) todo.Status));
            Assert.That(result.Todo.StatusName, Is.EqualTo(todo.Status.ToString()));
        }

        [Test]
        public async Task 存在しないTODOのIDを指定すると例外が発生する()
        {
            // 準備
            var userId = Guid.NewGuid().ToString();
            var todoId = TodoId.Generate().Value;

            // 実行・検証
            var command = new TodoGetCommand(
                userSession: new UserSession(userId),
                id: todoId);
            
            Assert.That(
                async () => await _todoGetUseCase.ExecuteAsync(command),
                Throws.TypeOf<UseCaseException>());
        }

        [Test]
        public async Task 他のユーザー所有するTODOのIDを指定すると例外が発生する()
        {
            // 準備
            var userId = Guid.NewGuid().ToString();

            var todo = TodoGenerator.Generate();
            await _todoRepository.SaveAsync(todo);

            // 実行・検証
            var command = new TodoGetCommand(
                userSession: new UserSession(userId),
                id: todo.Id.Value);

            Assert.That(
                async () => await _todoGetUseCase.ExecuteAsync(command),
                Throws.TypeOf<UseCaseException>());
        }
    }
}

ソースコード

完全に動くコードは以下から確認できます。気になる方はご参照ください。
GitHub - 記事に対応したバージョン

最新はこちらから確認できます。
GitHub - tatsuteb/todo-management-system

ロードマップ

先ずはシンプルなTODOを作ったわけですが、今後は以下のような機能を実装しながらドメイン駆動設計を実践してみようと思います。

  1. TODOに開始予定日、終了予定日を設定する機能
  2. ユーザアイコンを設定できる機能
  3. チームを作成してメンバーにTODOを割り当てる機能
  4. TODO内でチームメンバーとチャットする機能
  5. TODOをこなしてくれたメンバーにコーヒーを一杯おごる的な機能

機能を実装するたびにドメインモデル図のビフォー・アフターを載せていく予定です(どんな見せ方が良いか悩み中…)。

参考文献

何度も読み返した本、サイト

辞書的に利用した本

最後に

今後も学習用の仮想プロジェクトを立ち上げたら記録用の記事として残そうと思います。
実際に公開する場合、実装する機能によっては法務上の問題をクリアしたり届け出が必要だったりするので、その辺も共有できればと思います。

Discussion

ログインするとコメントできます