ドメインを純粋に保つ (レガシープロジェクトの改善活動について) (2)

先日、フレームワークの機能を利用する処理に染み込んでいるビジネス概念を抽出して、ドメインに移動して単体テストによって保護された状態にするという「ドメインを純粋に保つ」活動について説明してみました。

ドメインを純粋に保つ (レガシープロジェクトの改善活動について)

上の記事で使ったサンプルプロジェクトです。

上の記事のまとめでも書いたのですが、サンプルプロジェクトにはまだビジネス概念がありそうな箇所があるので、今回の続編ではそれらの中からビジネス概念を抽出してみようと思います。(ビジネス概念という言葉の説明については、上の記事を参照してください)

  • IssueController.php の searchメソッドの検索条件の処理
  • index.blade.php の行の背景を決定する処理 (←今回の抽出対象です)

まずは、index.blade.php の処理を見てみます。

ビュー resources/views/issue/index.blade.php

<?php

declare(strict_types=1);

use Domain\Issue\IssueStatus;

function get_issue_class(?string $deadline): string
{
    if (empty($deadline)) {
        return '';
    }
    $d = new \DateTimeImmutable($deadline);
    $today = new \DateTimeImmutable('today');
    if ($d == $today) {
        return 'today';
    } else if ($d < $today) {
        return 'dead';
    } else if ($d < $today->add(new \DateInterval('P3D'))) {
        return 'soon';
    }
    return '';
}
?>
@extends('layout.common')
@section('title', '一覧')

@section('content')
<h1>一覧表示</h1>
<a class="btn btn-primary" href="{{ route('issue.create') }}">{{ __('新規作成') }}</a>
<form method="GET" action="{{ route('issue.search') }}">
    @csrf
    <div>
        <input type="search" name="q" id="form-search" value="{{ request()->query('q') }}" class="form-control">
    </div>

    <button type="submit" class="btn btn-secondary">検索</button>
</form>

<table class="table">
    <tr>
        <th>ID</th>
        <th>件名</th>
        <th>状態</th>
        <th>期限</th>
        <th>内容</th>
        <th></th>
    </tr>
    @foreach($issues as $issue)
    <tr class="{{ get_issue_class($issue->deadline) }}">
        <td>{{$issue->id}}</td>
        <td>{{$issue->summary}}</td>
        <td>
            {{ IssueStatus::createFromValue($issue->status)->name() }}
        </td>
        <td>{{$issue->deadline}}</td>
        <td>{{$issue->description}}</td>
        <td><a href="{{ route('issue.show', ['issue' => $issue->id]) }}">詳細</a></td>
    </tr>
    @endforeach
</table>
{{ $issues->links() }}
@endsection

お行儀良いとは言えない実装ですが、ビューの中で  tabletr タグのクラスを決めるget_issue_class という関数が定義されています。まずは、処理内容を把握する必要があります。

処理内容

課題(Issue)の期限(deadline)によって、trタグのクラス名を返却しています。期限が今日であれば "today"を返却しています。既に期限を過ぎている場合は、"dead"を返却しています。期限が3日後に迫っていたら、"soon"を返却しています。この関数の返却値は、一覧の各課題の背景色を決定するためのクラス名となります。

ビジネス概念

この関数の処理は、特にデータベースやHTMLやCSSに引き摺られている訳でもないので、この部分はまるまるビジネス概念の候補です。このビジネス概念のしっくり来る名前が決まれば、ドメインに入れる作業に入れそうです。

この関数の処理の目的を考えてみます。課題の期限が切れていれば赤背景として表示、期限当日なら黄色背景、もうすぐならオレンジ背景となります。課題に設定した期限によって、どの課題を優先的に取り組まなければならないのかを可視化したいというものです。

バグ発見🐛

注意深い人は気がついているかもしれませんが、「どの課題を優先的に取り組まなければならないのかを可視化したい」という目的を真とした場合、明らかにバグが含まれています。(この記事を書いてる時に気がつきました💦)

目的が「どの課題を優先的に取り組まなければならないのかを可視化したい」である場合、状態が完了の課題の背景色が変わってしまうのは問題です。完了している課題であれば期限が過ぎているのは自然なことであり、課題を目立たせる理由がありません。

実際のレガシープロジェクトのビジネス概念の抽出をしている時にも、副次的にバグを発見するということはありがちです。

ビジネス概念が持つビジネスルールに含める形でバグも合わせて修正しようと思います。

ビジネス概念(again)

気を取りなおして、ビジネス概念の輪郭を明確にしていきます。「どの課題を優先的に取り組まなければならないのかを可視化したい」が目的であることが明確になったので、優先度として認識するのが良いかもしれません。しかし「○○度」というと数値にマッピングできそうなイメージがあるので微妙にしっくり来てないのですが、他にしっくり来る名前が思い付かないので、「優先度」という名前で進めてみます。

以下のビジネスルールを持つ課題の優先度(IsuuePriority)と名付けます。

  • 状態(IssueStatus)が未完了の課題について、優先度を判定します。
  • 期限が過ぎていたら、期限切れ("dead")と判定します。
  • 期限が今日なら、期限当日("today")と判定します。
  • 期限が3日以内に迫っていたら、もうすぐ("soon")と判定します。

テストコードを作成する tests/Unit/Issue/IssuePriorityTest.php

それでは、前回と同様に後で作成する対象のビジネス概念クラスが、外部からどう見えて欲しいか、どう使って欲しいかを考えながら、テストコードを記述していきます。

優先度を判定する際に現在日付を元に判定処理を行なうため、テストを書くのが難しくなります。今日という値がテスト実行時によって変わってしまうためです。それを回避するため、IssuePriorityクラスのコンストラクタで基準日を渡すことで、テストの書き易さも実現できるようにしています。

namespace Tests\Unit\Issue;

use Domain\Issue\IssuePriority;
use Domain\Issue\IssueStatus;
use PHPUnit\Framework\TestCase;

class IssuePriorityTest extends TestCase
{
    public function test_IssuePriority完了した課題の優先度は空文字になる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('done');
        $sut = new IssuePriority($baseDate, $status, '2023-01-01');
        $this->assertSame('', $sut->value());
    }

    public function test_IssuePriority期限が100年後の課題の優先度は空文字になる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('opened');
        $sut = new IssuePriority($baseDate, $status, '2123-01-01');
        $this->assertSame('', $sut->value());
    }

    public function test_IssuePriority期限が1ヶ月後の課題の優先度は空文字になる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('opened');
        $sut = new IssuePriority($baseDate, $status, '2023-02-01');
        $this->assertSame('', $sut->value());
    }

    public function test_IssuePriority期限が3日後の課題の優先度はsoonになる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('opened');
        $sut = new IssuePriority($baseDate, $status, '2023-01-04');
        $this->assertSame('soon', $sut->value());
    }

    public function test_IssuePriority期限が当日の課題の優先度はtodayになる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('opened');
        $sut = new IssuePriority($baseDate, $status, '2023-01-01');
        $this->assertSame('today', $sut->value());
    }

    public function test_IssuePriority期限が過去の課題の優先度はdeadになる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('opened');
        $sut = new IssuePriority($baseDate, $status, '2022-12-31');
        $this->assertSame('dead', $sut->value());
    }

    public function test_IssuePriority期限が不正な文字列の課題の優先度は空文字になる(): void
    {
        $baseDate = new \DateTimeImmutable('2023-01-01');
        $status = IssueStatus::createFromValue('opened');
        $sut = new IssuePriority($baseDate, $status, 'invalid date');
        $this->assertSame('', $sut->value());
    }
}

テストコードを作成する tests/Unit/Issue/IssueStatusTest.php

前回の記事で作成したIssueStatusに対しても、closedメソッドを追加することになったので、IssueStatusTest.php にもテストを追加しています。

<?php
namespace Tests\Unit\Issue;

use Domain\Issue\IssueStatus;
use PHPUnit\Framework\TestCase;

class IssueStatusTest extends TestCase
{
    public function test_IssueStatusの初期値は、openedであること(): void
    {
        $sut = IssueStatus::create();
        $this->assertSame('opened', $sut->value());
        $this->assertSame('新規', $sut->name());
        $this->assertSame(false, $sut->closed());
    }

    public function test_IssueStatusを値から作る_working(): void
    {
        $sut = IssueStatus::createFromValue('working');
        $this->assertSame('working', $sut->value());
        $this->assertSame('作業中', $sut->name());
        $this->assertSame(false, $sut->closed());
    }

    public function test_IssueStatusを値から作る_done(): void
    {
        $sut = IssueStatus::createFromValue('done');
        $this->assertSame('done', $sut->value());
        $this->assertSame('完了', $sut->name());
        $this->assertSame(true, $sut->closed());
    }

ビジネス概念の実装 domain/Issue/IssuePriority.php

今回抽出したビジネス概念である優先度のクラスを作成します。ビジネスルールに「状態(IssueStatus)が未完了の課題について、優先度を判定します。」のとおり、前回作成したビジネス概念が現れますので、前回作成したビジネス概念(IssueStatus)を使って実装します。(コンストラクタの第二引数として受け取ります)

<?php
declare(strict_types=1);

namespace Domain\Issue;

/**
 * Issueの優先度
 */
final class IssuePriority
{
    private string $value = '';

    /**
      * コンストラクタ
      * @param \DateTimeImmutable $baseDate 基準日
      * @param IssueStatus $status Issueの状態
      * @param ?string $deadline 期限
      */
    public function __construct( \DateTimeImmutable $baseDate, IssueStatus $status, ?string $deadline)
    {
        if ($status->closed()) {
            return;
        }
        if (empty($deadline)) {
            return;
        }
        try {
            $d = new \DateTimeImmutable($deadline);
            if ($d < $baseDate) { $this->value = 'dead';
            } else if ($d == $baseDate) {
                $this->value = 'today';
            } else if ($d < $baseDate->add(new \DateInterval('P4D'))) {
                $this->value = 'soon';
            }
        } catch (\Exception $e) {
            // DateTimeImmutableのコンストラクタに不正な日付文字列が渡された場合は例外が発生するが、
            // valueを空文字になるように何もしない
        }
    }

    /**
     * @return string 値
     */
    public function value(): string
    {
        return $this->value;
    }
}

ビジネス概念の実装 domain/Issue/IssueStatus.php

既存のIssueの状態のビジネス概念に、メソッド closedを追加しました。

<?php

declare(strict_types=1);

namespace Domain\Issue;

/**
 * Issueの状態
 */
final class IssueStatus
{
(略)
    /**
     * クローズ状態かどうかを返却します。
     * @return bool クローズ状態かどうか
     */
    public function closed(): bool
    {
        return in_array($this->value, ['done']);
    }
}

ビュー resources/views/issue/index.blade.php

既存の処理から作成したビジネス概念 IssuePriorityを使うよう修正します。ビューの中のお行儀の悪かったget_issue_class 関数は、丸ごと削除しました。

<table class="table">
    <tr>
        <th>ID</th>
        <th>件名</th>
        <th>状態</th>
        <th>期限</th>
        <th>内容</th>
        <th></th>
    </tr>
    @foreach($issues as $issue)
    <?php
        $status = IssueStatus::createFromValue($issue->status);
        $priority = new IssuePriority(new \DateTimeImmutable('today'), $status, $issue->deadline);
    ?>
    <tr class="{{ $priority->value() }}">
        <td>{{$issue->id}}</td>
        <td>{{$issue->summary}}</td>
        <td>
            {{ $status->name() }}
        </td>
        <td>{{$issue->deadline}}</td>
        <td>{{$issue->description}}</td>
        <td><a href="{{ route('issue.show', ['issue' => $issue->id]) }}">詳細</a></td>
    </tr>
    @endforeach
</table>

この記事での修正は、プルリクエストを作成しましたので、GitHub上で差分を確認することができます。

https://github.com/smeghead/legacy-sample/pull/2/files

ドメインのクラス図

php-class-diagram を使って、出力したクラス図です。

まとめ

前回、既存のソースコードからIssueの状態をビジネス概念として抽出したのに続いて、Issueの優先度をビジネス概念として抽出しました。今回は、完璧にしっくり来る名前を思い付けないけど適度に良い名前で妥協したり、途中で既存の処理に含まれるバグを発見して修正したり、泥臭い面もありましたが現実の開発でも頻繁に起こることなので生々しかったかもしれません。

これでドメインに2つの関連するビジネス概念が存在する形となりました。今後、ドメイン内にクラス等が増えてくれば、ドメイン内も適切にネームスペースを分割して管理する必要があります。この活動を継続して、ビジネス概念同士が適切な関連を持ちながら、純粋なビジネスルールを表現するドメインという領域を保守していくことを目指しています。前回の記事と合わせて読んでいただけると、段階的に改善していく際の考え方を把握しやすいのではないかと思います。最後まで読んでいただきありがとうございます。フィードバック等いただけると嬉しいです。

 

続く

ドメインを純粋に保つインデックス

シリーズ化してますので、他記事もご覧ください。

2件のピンバック

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です


reCaptcha の認証期間が終了しました。ページを再読み込みしてください。

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください