ありあけこういち’s diary

SaaS企業Webエンジニア|HSK6級|福岡ラバー|木管楽器|ITについて、明快な読み物を目指しています。

ユースケースについて

はじめに

IT用語で広く使われている「ユースケース」という用語と、どのようにコード上に起こしていくか、特にドメイン駆動設計とクリーンアーキテクチャユースケースをどう表現するのかをまとめています。

ユースケースの概要

ユースケースとは

あるシステムやサービスが提供する機能を表現する概念で、利用者が行う一連の処理や操作を記述します。システムの全体像を捉えたり、要件定義にも活用されます。
以下は引用です。

サクッと一言で説明すると「システムの活用事例を図とかで何となく表現してみるから、それを見て、どんなシステムになるかイメージしようぜ!」なやり方が「ユースケース」です。 -- 「分かりそう」で「分からない」でも「分かった」気になれるIT用語辞典

ドメイン駆動設計でのユースケースの表現

ドメイン駆動設計では、ユースケースという概念をアプリケーションサービスを利用し実装します。

アプリケーションサービスを端的に表現するならば、ユースケースを実現するオブジェクトです。 -- 成瀬 允宣. ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 (Japanese Edition) (p.185). Kindle 版.

アプリケーションサービスとは

外部からの入力を受け取り、必要な処理をドメイン層に依頼し、ドメイン層のオブジェクトを利用してビジネスロジックを実行し返された結果を加工して外部に返す役割を担います。

アプリケーションサービスについては、下記の記事でもコードの例を併せてまとめていますので、宜しければご確認ください。

jipai1121.hatenablog.com

クリーンアーキテクチャでのユースケースの表現

クリーンアーキテクチャでのユースケースは、Application Business Rules層にて表現します。 アプリケーションの重要なビジネスロジックを担当する層であり、Enterprise Business Rules層に依存します。
外の層であるInterface Adapters層やFrameworks & Drivers(データベース、UI、フレームワーク)の変更の影響は受けないように実装します。
以下は流用です。

Application Business Rules 赤いレイヤーは Application Business Rules です。 このレイヤーは「ソフトウェアが何ができるのか」を表現します。 Enterprise Business Rules に所属するオブジェクトを協調させ、ユースケースを達成します。 ドメイン駆動設計でいうところのアプリケーションサービスなんかはここの住人です。 -- 実装クリーンアーキテクチャ

図の引用元:https://qiita.com/nrslib/items/a5f902c4defc83bd46b8

phpコード例

以下は商品登録のユースケースをクリーンアークテクチャに基づいてphpコードに起こしたものです。
下記はバックエンドのコードでして、Frameworks & Drivers層およびPresenterの実装については省いております。*1
処理の流れをまとめると以下のようになります。

  1. ProductControllerがRegisterProductInputDataとRegisterProductOutputDataのインスタンスを作り、RegisterProductInteractor(ユースケース)に渡す。
  2. RegisterProductInteractorのhandleメソッドで商品の登録処理を行います。入力データから商品名と価格を取得し、商品ファクトリーを使って新しい商品オブジェクトを生成し、商品リポジトリに保存します。また、出力データ(RegisterProductOutputData)に商品の情報を設定します。
  3. ProductControllerでjsonのレスポンス形としてデータ整形を行い、レスポンスを返します。
Enterprise Business Rules(エンティティ層)
<?php
declare(strict_types=1);

namespace App\Product;

use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductId;
use App\Product\ValueObject\ProductName;

// 商品エンティティ
class Product
{
    private ProductId $productId;
    private ProductName $productName;
    private Price $price;

    public function __construct(
        ProductId $productId,
        ProductName $productName,
        Price $price
    ){
        $this->productId = $productId;
        $this->productName = $productName;
        $this->price = $price;
    }

    /**
     * @return ProductId
     */
    public function getProductId(): ProductId
    {
        return $this->productId;
    }

    /**
     * @return ProductName
     */
    public function getProductName(): ProductName
    {
        return $this->productName;
    }

    /**
     * @param ProductName $productName
     */
    public function setProductName(ProductName $productName): void
    {
        $this->productName = $productName;
    }

    /**
     * @return Price
     */
    public function getPrice(): Price
    {
        return $this->price;
    }

    /**
     * @param Price $price
     */
    public function setPrice(Price $price): void
    {
        $this->price = $price;
    }
}
?>
Application Business Rules(ユースケース層)

Enterprise Business Rules(エンティティ層)に依存します。

<?php
declare(strict_types=1);

namespace App\Product\ApplicationBusinessRules;

use App\Product\Product;
use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductId;
use App\Product\ValueObject\ProductName;

//Application Business Rules
class ProductFactory implements ProductFactoryInterface
{
    public function create(ProductName $productName, Price $price): Product {
        $productId = new ProductId(uniqid());
        return new Product($productId, $productName, $price);
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\ApplicationBusinessRules;

use App\Product\Product;
use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductName;

//Application Business Rules
interface ProductFactoryInterface
{
    public function create(ProductName $productName, Price $price): Product;
}
?>

<?php
declare(strict_types=1);

namespace App\Product\ApplicationBusinessRules;

use App\Product\Exception\SaveProductException;
use App\Product\InterfaceAdapters\ProductRepositoryInterface;
use App\Product\InterfaceAdapters\RegisterProductInputPort;
use App\Product\InterfaceAdapters\RegisterProductOutputPort;

//Application Business Rules
class RegisterProductInteractor {
    private ProductRepositoryInterface $repository;
    private ProductFactoryInterface $factory;

    public function __construct (
        ProductRepositoryInterface $repository,
        ProductFactoryInterface $factory
    ) {
        $this->repository = $repository;
        $this->factory = $factory;
    }

    /**
     * @param RegisterProductInputPort $input
     * @param RegisterProductOutputPort $output
     * @return void
     */
    public function handle(RegisterProductInputPort $input, RegisterProductOutputPort $output): void {

        try {
            $productName = $input->getProductName();
            $price = $input->getPrice();
            $product = $this->factory->create($productName, $price);
            $this->repository->save($product);

            $output->setProductId($product->getProductId());
            $output->setProductName($product->getProductName());
            $output->setPrice($product->getPrice());
        } catch (SaveProductException $e) {
            // リポジトリでのexception
            throw $e;
        }
    }
}
?>
値オブジェクト
<?php
declare(strict_types=1);

namespace App\Product\ValueObject;

// 値オブジェクト:商品価格
class Price
{
    /**
     * @var int
     */
    private int $value;

    /**
     * @param int $value
     */
    public function __construct(int $value)
    {
        $this->value = $value;
    }

    /**
     * @return int
     */
    public function toInt(): int
    {
        return $this->value;
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\ValueObject;

// 値オブジェクト:商品ID
class ProductId
{
    /**
     * @var string
     */
    private string $value;

    /**
     * @param string $value
     */
    public function __construct(string $value)
    {
        $this->value = $value;
    }

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

<?php
declare(strict_types=1);

namespace App\Product\ValueObject;

// 値オブジェクト:商品名
class ProductName
{
    /**
     * @var string
     */
    private string $value;

    /**
     * @param string $value
     */
    public function __construct(string $value)
    {
        $this->value = $value;
    }

    /**
     * @return string
     */
    public function __toString(): string
    {
        return $this->value;
    }
}
?>
独自例外
<?php
declare(strict_types=1);

namespace App\Product\Exception;

use RuntimeException;
use Throwable;

class DeleteProductException extends RuntimeException
{
    public function __construct(
        string $message = "商品を削除できませんでした。",
        int $code = 500,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\Exception;

use RuntimeException;
use Throwable;

class FindProductException extends RuntimeException
{
    public function __construct(
        string $message = "商品情報の取得に失敗しました。",
        int $code = 500,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\Exception;

use RuntimeException;
use Throwable;

class NotFoundProductException extends RuntimeException
{
    public function __construct(
        string $message = "商品が存在していません。",
        int $code = 404,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\Exception;

use RuntimeException;
use Throwable;

class SaveProductException extends RuntimeException
{
    public function __construct(
        string $message = "商品の保存に失敗しました。",
        int $code = 500,
        ?Throwable $previous = null
    ) {
        parent::__construct($message, $code, $previous);
    }
}
?>
Interface Adapters

Application Business Rules(ユースケース層)に依存します。

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;
use App\Product\ApplicationBusinessRules\RegisterProductInteractor;
use App\Product\Exception\SaveProductException;
use InvalidArgumentException;

//Interface Adapters
class ProductController
{
    // ステータスコードの定数を定義
    const HTTP_OK = 200;
    const HTTP_BAD_REQUEST = 400;
    const HTTP_INTERNAL_SERVER_ERROR = 500;

    private RegisterProductInteractor $usecase;
    public function __construct(
        RegisterProductInteractor $usecase
    )
    {
        $this->usecase = $usecase;
    }

    public function register(array $request): string {

        try {
            $this->validation($request);
            $input = new RegisterProductInput($request['name'], $request['price']);
            $output = new RegisterProductOutput();
            $this->usecase->handle($input, $output);

            // 表示するデータを整形して返す
            $response = [
                'id' => $output->getProductId(),
                'name' => $output->getProductName(),
                'price' => $output->getPrice()
            ];
            http_response_code(self::HTTP_OK); // 成功時のステータスコードを設定
            return json_encode($response);
        } catch (InvalidArgumentException $e) {
            $response = [
                'code' => self::HTTP_BAD_REQUEST,
                'message' => $e->getMessage()
            ];
            http_response_code(self::HTTP_BAD_REQUEST);
            return json_encode($response);
        } catch (SaveProductException $e) {
            $response = [
                'code' => $e->getCode(),
                'message' => $e->getMessage()
            ];
            http_response_code(self::HTTP_INTERNAL_SERVER_ERROR);
            return json_encode($response);
        }
    }

    private function validation(array $request): void
    {
        if (empty($request['name'])) {
            throw new InvalidArgumentException('Name is required');
        }

        if (empty($request['price'])) {
            throw new InvalidArgumentException('Price is required');
        }

        if (!is_numeric($request['price'])) {
            throw new InvalidArgumentException('Price must be a number');
        }
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;
use App\Product\Exception\DeleteProductException;
use App\Product\Exception\FindProductException;
use App\Product\Exception\NotFoundProductException;
use App\Product\Exception\SaveProductException;
use App\Product\Product;
use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductId;
use App\Product\ValueObject\ProductName;
use PDO;
use Throwable;

//Interface Adapters
class ProductRepository implements ProductRepositoryInterface
{
    private const NONE_PRODUCT = 0;
    private PDO $pdo;

    public function __construct() {
        $this->pdo = new PDO('mysql:dbname=test;host=localhost', 'test', 'password');
    }

    public function findById(ProductId $id): Product
    {
        try {
            $sql = "SELECT * FROM products WHERE id = :id";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute(['id' => (string)$id]);

            if ($stmt->rowCount() === self::NONE_PRODUCT) {
                throw new NotFoundProductException("商品が存在していません。", 404);
            }
            $product = $stmt->fetch(PDO::FETCH_ASSOC);

            $productId = new ProductId($product['id']);
            $productName = new ProductName($product['name']);
            $productPrice = new Price($product['price']);

            return new Product($productId, $productName, $productPrice);
        } catch (NotFoundProductException $e) {
            //独自例外をそのままエスカレーション
            throw $e;
        } catch (Throwable $e) {
            throw new FindProductException($e->getMessage(), $e->getCode(), $e);
        }

    }

    public function save(Product $product): void
    {
        try {
            $sql = "INSERT INTO products (id, name, price) VALUES (:id, :name, :price)
               ON DUPLICATE KEY UPDATE name = :name, price = :price";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute([
                'id' => (string)$product->getProductId(),
                'name' => (string)$product->getProductName(),
                'price' => $product->getPrice()->toInt()
            ]);
        } catch (Throwable $e) {
            throw new SaveProductException($e->getMessage(), $e->getCode(), $e);
        }
    }

    public function delete(ProductId $productId): void
    {
        try {
            $sql = "DELETE FROM products WHERE id = :id";
            $stmt = $this->pdo->prepare($sql);
            $stmt->execute([
                'id' => (string)$productId
            ]);
        } catch (Throwable $e) {
            throw new DeleteProductException($e->getMessage(), $e->getCode(), $e);
        }
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;

use App\Product\Product;
use App\Product\ValueObject\ProductId;

//Interface Adapters
interface ProductRepositoryInterface
{
    public function findById(ProductId $id): Product;
    public function save(Product $product): void;
    public function delete(ProductId $productId): void;
}
?>

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;

use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductName;

//Interface Adapters
class RegisterProductInput implements RegisterProductInputPort
{
    private ProductName $productName;
    private Price $price;

    public function __construct(string $productName, int $price)
    {
        $this->productName = new ProductName($productName);
        $this->price = new Price($price);
    }

    public function getProductName(): ProductName
    {
        return $this->productName;
    }

    public function getPrice(): Price
    {
        return $this->price;
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;

use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductName;

//Application Business Rules
interface RegisterProductInputPort
{
    public function getProductName(): ProductName;
    public function getPrice(): Price;
}
?>

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;

use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductId;
use App\Product\ValueObject\ProductName;

//Interface Adapters
class RegisterProductOutput implements RegisterProductOutputPort
{
    private string $productId;
    private string $productName;
    private int $price;

    public function getProductId(): string
    {
        return $this->productId;
    }

    public function getProductName(): string
    {
        return $this->productName;
    }

    public function getPrice(): int
    {
        return $this->price;
    }

    public function setProductId(ProductId $productId): void {
        $this->productId = (string)$productId;
    }

    public function setProductName(ProductName $productName): void {
        $this->productName = (string)$productName;
    }

    public function setPrice(Price $price): void {
        $this->price = $price->toInt();
    }
}
?>

<?php
declare(strict_types=1);

namespace App\Product\InterfaceAdapters;

use App\Product\ValueObject\Price;
use App\Product\ValueObject\ProductId;
use App\Product\ValueObject\ProductName;

//Interface Adapters
interface RegisterProductOutputPort
{
    public function getProductId(): string;
    public function getProductName(): string;
    public function getPrice(): int;
    public function setProductId(ProductId $productId): void;
    public function setProductName(ProductName $productName): void;
    public function setPrice(Price $price): void;
}
?>
値段が200の商品名が「じゃがいも」の商品を登録する使用例
<?php
declare(strict_types=1);

require('../backend/vendor/autoload.php');

use App\Product\ApplicationBusinessRules\ProductFactory;
use App\Product\ApplicationBusinessRules\RegisterProductInteractor;
use App\Product\Exception\SaveProductException;
use App\Product\InterfaceAdapters\ProductController;
use App\Product\InterfaceAdapters\ProductRepository;

$registerProductInteractor = new RegisterProductInteractor(new ProductRepository(), new ProductFactory());
$controller = new ProductController($registerProductInteractor);

$request = [
    'name' => 'じゃがいも',
    'price' => 200
];

$response = $controller->register($request);
echo $response;
?>

参考記事、書籍

*1:Presenterを使わない理由は、今回作るコードはバックエンドの実装であり、表示する実装がないため、表示のためのデータ成形を行うPresenterは特にいらないと思っています。