ドメイン駆動設計(DDD)の最小サンプル

背景

成瀬允宣さんのドメイン駆動設計入門を読みました。
正直言って、半分も理解できていません。
この本に関するサンプルソフトがダウンロードできますので、してみました。
でも、僕にとっては複雑過ぎました。
そこで、自分で必要最小限のサンプルコードを書いて理解を深めることにしました。

レイヤ構成

以下、ドメイン駆動設計(domain-driven design)をDDDと略します。

DDDは、4レイヤ(層)から構成されます。

  1. Domain層
  2. Presentation層
  3. Infrastructure層
  4. Use case層(=Application層)

Domain層は実システムをモデル化する層です。例えば、「名簿で使うIDがアルファベットの大文字1文字と数字3文字からなる文字列で構成する」ようなことを表現します。

Presentation層は、人間とのインターフェイスを司る層です。キーボード入力やディスプレイ出力の制御等がそれにあたります。

Infrastructure層は、ハードウェア間の制御を司る層です。ファイルやデータベースへの入出力がそれにあたります。

Use case層は上記3つの層に含まれない事項を取り扱う層です。

DDDのレイヤの依存関係を図示します。

DDDのレイヤ構成

DDDのレイヤ構成

矢印は2種類あります。

通常の矢印(①、②、④)は、依存を示しています。
例えば、Domain層に「ID」というクラスがあった場合、④矢印の根元側のUse case層では、このIDを使ってコードを書くことができます。

白三角の矢印(③、⑤)は、逆転された依存を示しています。
例えば、Use case層からInfrastructure層にある「Repository」クラスを使おうとした場合、Use caseは③矢印の先端側なので、直接的には使えません。そこで、Use case層では、層内にIRepositoryというインターフェイスを作り、それを用いたコードを書きます。Infrastructre層のRespositoryクラスは、③矢印によりこのIRepositoryが使えますから、このインターフェイス仕様に合うようにRepositoryクラスのコードを書きます。

実行時における動作は次の通りです。Presentation層は、Infrastructure層にIRepositoryに従ったRepositoryクラスがあることを②矢印により認識できます。さらに、①の経路を使うと、Use case層のクラスに、この対応関係を引数として伝える(依存性を注入する)ことができます。この結果、Use caseのコードはRepositoryクラスを使えるようになります。

ここで注目すべきは、Domain層を根元とする矢印が無いことです。つまり、Domain層は他の層に依存しないので、他の層をいくら変更しても変更する必要がありません。これが、ドメイン駆動設計の神髄です。

(ネット上に、②の無いDDDのレイヤ構成図を見かけることがあります。その場合、依存性の注入ができるのでしょうか?)

作ろうとしているアプリケーション

社員名簿

社員名簿

ここでは、従業員IDを入力すると、Excelで作成された社員名簿の原本から社員名を検索するアプリケーションを作ります。
従業員IDは、アルファベットの大文字1文字と数字3桁で構成されることとします。また、社員名は、姓と名の間に半角スペースを1つ入れることとします。(これらがドメイン知識です)

サンプルコード

プロジェクトの作成

早速、サンプルコードを作成していきたいと思います。
言語は、Visual Studio 2022のC#を使います。(今までC#を使ったことないけど(汗))

先ず、Visual Studioを立ち上げて、「新しいプロジェクトの作成」を選びます。
言語は「C#」、プラットフォームは「Windows」、プロジェクトの種類は「デスクトップ」を選びます。

Windows フォームアプリ

Windows フォームアプリ

出てきた中から「Windows フォーム アプリ」を選択→「次へ」

新しいプロジェクト

新しいプロジェクト

フォームはPresentation層に含まれるので、プロジェクト名を「Presentation」とします。
ソリューション名は「MinDDD」とでもします。
これからプロジェクトを3つ追加しますので、「ソリューションとプロジェクトを同じディレクトリに配置する」のチェックを「外し」ます。ソリューションは複数のプロジェクトを束ねるので、特定のプロジェクトとだけ同じフォルダにするのは好ましくありません。
場所は適当な場所を指定してください。
「次へ」をクリックします。

追加情報

追加情報

追加情報は、特に変更を加えません。自分の環境では「.NET 8.0(長期的なサポート)」でした。
「作成」をクリックします。

ソリューションエクスプローラ

ソリューションエクスプローラ

IDE(統合開発環境)の右上のソリューションエクスプローラを見るとMinDDDソリューションの下にPresentationプロジェクトが確認できます。このMinDDDソリューションに、Domain、Infrastructure、UseCase層をプロジェクトとして追加していきます。

クラスライブラリ

クラスライブラリ

「ソリューション ‘MinDDD’・・・」を右クリックして、「追加」→「新しいプロジェクト」とし、プロジェクトの種類を「ライブラリ」に変えて、(.NET Frameworkの付いていない)「クラスライブラリ」を選び、「次へ」をクリックします。

Domainプロジェクト

Domainプロジェクト

プロジェクト名に「Domain」と入力し、場所はそのままで「次へ」をクリックします。
追加情報は、やはり変更不要で「次へ」です。
同様にして、「Infrastructure」と「UseCase」プロジェクトも追加します。

プロジェクトを追加した状態

プロジェクトを追加した状態

図のように、MinDDDソリューションの下に、Domain、Infrastructre、Presentation、UseCaseの4プロジェクトが配置できましたでしょうか?

依存関係の設定

次に、依存関係を設定します。
DDDのレイヤ構成の図を見ると、Presentation層は、①の矢印でUseCase層、②の矢印でInfrastructre層に依存していることがわかります。

参照マネージャー

参照マネージャー

そこで、「Presentation」プロジェクトのすぐ下の「依存関係」(表示がされていない場合は、「Presentation」の左脇の▷をクリック)を右クリックして、「プロジェクト参照の追加」を選び、出てきた「参照マネージャー」の「ソリューション」タブにおいて、InfrastructureとUseCaseをチェックし、「OK」をクリックします。

同様にして、「Infrastructure」プロジェクトは、「UseCase」と「Domain」にチェックを入れ、「UseCase」プロジェクトは、「Domain」にチェックを入れます。

依存関係

依存関係

ソリューションエクスプローラで依存関係を確認することができます。

Domainプロジェクトのサンプルコード

「ID」(従業員ID)クラスのサンプルコード

従業員IDのサンプルコードを設定します。
DomainプロジェクトにClass1.csがありますので、この名前をIDに変えます。「Class1.cs」を右クリックし、「名前の変更」を選び、「ID.cs」を入力し、Enterを押します。
続いて、「ID.cs」を選択するとエディタが開きます。開かない場合は「F7」キーを押してください。
そこに次のコードをコピー&ペーストします。

using System.Text.RegularExpressions;

namespace Domain
{
    public class ID
    {
        public ID(string? value)
        {
            if (value == null) throw new ArgumentNullException(nameof(value));
            if (!Regex.IsMatch(value, "^[A-Z][0-9]{3}$"))
            { throw new ArgumentException($"{nameof(ID)} の {nameof(value)} が \"^[A-Z][0-9]{{3}}$\" にマッチしません"); }
            Value = value;
        }
        public string Value { get; }
    }
}

従業員IDは、アルファベットの大文字1文字と数字3文字からなる文字列以外が許されない仕様です。また、Valueというプロパティで、値の読み出しはできますが、値の設定はできません。
(個人的には、このようなsetができないクラスのことが値オブジェクトだと思っています。)

Name(氏名)クラスのサンプルコード

次に氏名を示すName.csのサンプルコードを示します。Name.csも値オブジェクトです。

「ソリューションエクスプローラー」の「Domain」プロジェクトを右クリックし、「追加」、「新しい項目」を順に選択します。

Name.cs

Name.cs

「クラス」のアイコンをクリックしてから名前の欄に「Name.cs」と入力して、「追加」をクリックします。
現れたエディタに次のコードをコピー&ペーストします。

using System.Text.RegularExpressions;

namespace Domain
{
    public class Name
    {
        public Name(string? value)
        {
            if (value == null) throw new ArgumentNullException(nameof(value));
            if (!Regex.IsMatch(value, "^[^0-9]+ [^0-9]+$"))
            { throw new ArgumentException($"{nameof(Name)} の {nameof(value)} が \"^[^0-9]+ [^0-9]+$\" にマッチしません"); }
            Value = value;
        }
        public string? Value { get; }
    }
}

このコードは、姓と名の間には半角スペース1文字分を開けることと、半角のアラビア数字が使えないことを示しています。

Employeeクラスのサンプルコード

Employee.csクラスは、Name.csクラスと同様の手順で作成してください。
コードは以下となります。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Domain
{
    public class Employee
    {
        public Employee(ID? id, Name? name)
        {
            if(id == null) throw new ArgumentNullException(nameof(id));
            ID = id;
            Name = name;
        }
        public ID ID { get; }
        public Name? Name { get; private set; }

    }
}

Empoyeeは、IDと名前をまとめた「従業員」というクラスです。IDを識別子としてgetのみとし、Nameをsetできるようにして、エンティティにしたつもりです。Nameは、結婚や改名で変わることを想定しています。

Presentationプロジェクトのサンプルコード

次にPresentation層を作ることにします。
まず、From1.csをFormMain.csという名前に変えます。単純に好みの問題です。
FormMain.csをダブルクリックすると、デザインの画面が開きます。

フォームの作成

フォームの作成

左上のツールボックス(無い時には「表示」タブ→「ツールボックス」を選択)から「Label」と「TextBox」を2つずつドラッグ&ドロップして適当に配置します。

その後、上のLabelをクリックして右下のプロパティ(無い時には「表示」タブ→「プロパティウィンドウ」を選択)のText欄に「ID」と入力します。また、下のラベルをクリックしてText欄に「名前」と入力します。

さらに、上のTextBoxをクリックして、「(Name)」欄に「textBoxID」、下のTextBoxをクリックして、「(Name)」欄に「textBoxName」と入力します。大文字、小文字を区別するので気をつけてください。
つづいて、「F7」キーを押し、開いたエディタに次のコードをコピー&ペーストします。

using Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using System.Diagnostics;
using UseCase;

namespace Presentation
{
    public partial class FormMain : Form
    {
        private readonly ServiceCollection _services = new();
        public FormMain()
        {
            InitializeComponent();

            // NuGet で Microsoft.Extensions.DependencyInjection をインストールしておく
            _services.AddTransient<IRepository, Repository>();

            // テキストボックスをクリア
            this.textBoxID.Text = ""; 
            this.textBoxName.Text = "";
        }

        private void TextBoxID_KeyDown(object sender, KeyEventArgs e)
        {
            if (e.KeyCode == Keys.Enter)
            {
                IRepository? repository = _services.BuildServiceProvider().GetService<IRepository>();
                if (repository == null)
                {
                    MessageBox.Show($"{nameof(repository)}がnullです", "エラー",
                        MessageBoxButtons.OK, MessageBoxIcon.Error);
                    return;
                }
                ApplicationService applicationService = new(repository);
                if (applicationService.Analyze(this.textBoxID.Text, out string? employeeName, out string? message))
                { this.textBoxName.Text = employeeName; }
                else
                { MessageBox.Show(message, "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error); }
            }
        }
    }
}

このコードのコンストラクタは、初期化の後、インターフェイスとリポジトリの関係を「AddTransient」で紐付けます。さらに、テキストボックスをクリアします。

textBoxIDの上でEnterキーが押されると、Repositoryを依存注入しながらUseCase層のApplicationServiceをインスタンスします。さらに、textBoxIDの値を入力引数としてAnalyzeメソッドを呼びます。Analyzeメソッドは、IDから従業員名を解析してemployeeNameに返してくるので、それをtextBoxNameに表示します。

Extensions.DependencyInjection

Extensions.DependencyInjection

ここで、依存性の注入に、Microsoft.Extensions.DependencyInjectionを使うので、NuGetパッケージをインストールします。
「Presentation」プロジェクトを右クリック→「NuGetパッケージの管理」→「参照」
テキストボックスに「Extensions.Dependency」などと入力すると「Microsoft.Extensions.DependencyInjection」(似た)名前が複数あるので注意!)が出てくるので、選択し、右の欄の「インストール」をクリックします。

TextBox_KeyDown

TextBox_KeyDown

また、textBoxIDとイベントを結び付けるおまじないが必要です。
textBoxIDをクリックした状態で、右下のプロパティ欄からイナズママークをクリックします。スクロールバーを上下して、「KeyDown」を見つけたところで右側の欄の右端に出てくる下矢印をクリックすると、「TextBoxID_KeyDown」が表示されますので、選択してください。表示されない場合はこの通りに手入力してEnterを押してください。

Infrastructureプロジェクトのサンプルコード

Infrastructreプロジェクトでは、「Class1.cs」の名前を「Repository.cs」に変えて、以下のコードをコピー&ペーストします。

using Domain;
using System.Collections.Generic;
using System.Data.OleDb;
using System.Runtime.Versioning;
using UseCase;

namespace Infrastructure
{
    public class Repository : IRepository
    {
        [SupportedOSPlatform("windows")]
        public Name? Find(ID id)
        {
            string employeeListFile = $"{System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase}EmployeeList.xlsx";
            string connectionString = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" +
                employeeListFile + ";Extended Properties=Excel 12.0";
            string cmdText = $"SELECT Name FROM [Sheet1$] WHERE ID = '{id.Value}'";
            using OleDbConnection connection = new(connectionString);
            OleDbCommand command = new(cmdText, connection);
            connection.Open();
            using OleDbDataReader reader = command.ExecuteReader();
            Name? name = reader.Read() ? new Name($"{reader[0]}") : null;
            connection.Close();
            return name;
        }
    }
}

このコードは、値オブジェクトとしてのIDを引数として受け取り、その値(id.Value)を取り出します。そして、その値をキーとして、EmployeeList.xlsxというExcelファイルのSheet1のID列(A列)を検索し、一致した行のName列(B列)の値を取得してNameオブジェクトにして返します。

Infrastructureプロジェクトでは、ExcelをDBとして扱いたいので、NuGetパッケージの「System.Data.OleDb」をインストールしておきます。

EmployeeList.xlsx

EmployeeList.xlsx

また、図を参考に、A列にID、B列にNameを記入したEmployeeList.xlsxを作成してください。シート名はSheet1のままにしておきます。さらに、Windowsのエクスプローラを使って、Infrastructreフォルダ直下に入れてください。
すると、ソリューションエクスプローラで見えるようになるので、Excelファイルをクリックし、プロパティの「出力ディレクトリにコピー」に対する選択肢を「新しい場合はコピーする」にします。

UseCaseプロジェクトのサンプルコード

UseCaseプロジェクトでは、「Class1.cs」の名前を「ApplicationService.cs」に変えて、以下のコードをコピー&ペーストします。

using Domain;
using System.Net.Http.Headers;

namespace UseCase
{
    public class ApplicationService
    {
        private readonly IRepository _repository;
        public ApplicationService(IRepository repository)
        {
            _repository = repository;
        }
        public bool Analyze(string? value, out string? employeeName, out string? message)
        {
            employeeName = null;
            message = null;
            try
            {
                ID id = new(value);
                Name? name = _repository.Find(id);
                if (name == null)
                {
                    message = "名簿に名前がありません";
                    return false;
                }
                employeeName = name.Value;
                Employee member = new(id, name);    // 使い道は特にない
                return true;
            }
            catch (Exception ex)
            {
                message = ex.Message;
                return false;
            }
        }
    }
}

このコードは、コンストラクタで、Presentation層からIRepositoryというインターフェイスについて依存性の注入を受けます。Analyzeメソッドは、引数として従業員IDを受け取り、Repositoryで名前を調べ、それを出力します。

インターフェイス

インターフェイス

インターフェイスであるIRepositoryは次のようにして作ります。先ず、「新しい項目の追加」のところで、「クラス」の代わりに「インターフェイス」を選び、名前欄に「IRepository.cs」と入力します。
その後、以下のコードをコピー&ペーストします。

using Domain;

namespace UseCase
{
    public interface IRepository
    {
        public Name? Find(ID id);
    }
}

このインターフェイスは、これに従うクラスを作成する際は、IDという引数を1つ持ち、Name型を返すFindというメソッドを持つことを規定しています。

動作確認

ツールバーの「▶Presentation」をクリックして実行します。
フォームが開いたらIDの欄に「A100」と入力してEnterを押します。
名前の欄に「一寸 法師」と出たら正常動作しています。

まとめ

ドメイン駆動設計で、ほぼ最低限のサンプルコードを示しました。
すなわち、Domain層は値オブジェクトが2つ、エンティティが1つ、UseCase層はインターフェイス1つとそれに沿ったクラスが1つ、Infrastructre層はこのインターフェイスを持つクラスが1つとExcelファイルが1つ、Presentation層はフォームが1つ、という構成です。
ここまでしないと1つのソフトができないことを面倒と考えるか、メンテナンスが容易になると喜ぶかはあなた次第です。

ところで、本文を書いていて、直したくなるところが何か所かありました。どことは言いませんが・・・

あっ、そうそう、Debug時はこのままでいいのですが、Release時にはビルドの成果物を共通ディレクトリに配置したくなると思います。その際には、下記URLの「すべてのソリューション出力を共通ディレクトリに配置するには」あたりを参考になさってください。

ビルド出力ディレクトリを変更する - Visual Studio (Windows)
Visual Studio 内で、(デバッグ、リリース、またはその両方の) 構成ごとにプロジェクトによって生成された出力の場所を指定します。

コメント

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