Help us understand the problem. What is going on with this article?

Laravelで実践DDD

はじめに

DDDはScalaやJavaで使われることが多く、PHPでは使われてる例が少なかったため、
試行錯誤しながら実装した結果を記載することにしました。

本来では、コードを書く前にユビキタス言語ICONIXコンテキストマップ等の設計から入るべきですが、シンプルな題材にすることで省略しました。

なぜLaravelでDDD?

昔はコード設計そこまで意識しておらず、フレームワークに従ってMVCが出来ていればいいやぐらいの気持ちだったのですが、大規模なサービスや変更が多いシステムでは限界を感じることが多々ありました。

ScalaでDDDを経験した際に、システムの拡張が容易で変更に強い設計だと感じてからDDDに興味を持ち、最近使う機会が多いLaravelで実践してみようと思い今回記事にしました。

題材

今回はユーザを登録するだけのアプリケーションを作ります。
これを元に今後機能を追加していく予定です。

実装

環境

  • PHP 7.3
  • Laravel 5.8

本記事に記載されているコードはGitHubに置いてます。

ユーザテーブルの作成

ユーザは名前、メールアドレス、パスワードの情報を持ちます。

マイグレーションのイメージ
database/migrations/2019_05_26_080924_create_users_table.php
public function up()
{
    Schema::create('users', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->string('email')->unique();
        $table->string('password');
        $table->timestamps();
    });
}

コントローラ

コントローラはリクエストを受け取りサービスに投げるだけに留めてます。
フォームリクエストにバリデーションに委ねることができるため、とてもシンプルになります。

コントローラーの作成
app/Http/Controllers/UserController.php
/**
 * @return View
 */
public function index(): View
{
    $users = (new UserService())->list();
    return view('user.list', ['users' => $users]);
}

/**
 * @return View
 */
public function create(): View
{
    return view('user.create', ['user' => \Session::get('_old_input')]);
}

/**
 * @param StoreUserPost $request
 * @return RedirectResponse
 */
public function store(StoreUserPost $request): RedirectResponse
{
    $user = $request->all();
    if ((new UserService())->store($user)) {
        return redirect('/user/')->with('success_message', trans('message.success_save'));
    }
    return redirect('/user/')->with('error_message', trans('message.failed_save'));
}

フォームリクエスト

ユーザ入力画面からのリクエストをバリデーションします。
この際に値オブジェクトの定数を使い、ドメイン知識の分散を防ぎます。

フォームリクエストの作成
app/Http/Requests/StoreUserPost.php
public function up()
/**
 * @return array
 */
public function rules(): array
{
    $name_between = User\Name::MIN_LENGTH . ',' . User\Name::MAX_LENGTH;
    $pass_between = User\Password::MIN_LENGTH . ',' . User\Password::MAX_LENGTH;
    return [
        'name' => 'required|string|between:' . $name_between,
        'email' => 'required|unique:users|email',
        'password' => 'required|string|between:' . $pass_between,
    ];
}

/**
 * @return array
 */
public function messages(): array
{
    return [
        'name.required' => trans('validation.required', ['attribute' => 'ユーザ名']),
        'name.string' => trans('validation.string', ['attribute' => 'ユーザ名']),
        'name.between'  => trans('validation.between', [
            'attribute' => 'ユーザ名',
            'min' => User\Name::MIN_LENGTH,
            'max' => User\Name::MAX_LENGTH
        ]),
        'email.required' => trans('validation.required', ['attribute' => 'メールアドレス']),
        'email.unique' => trans('validation.unique', ['attribute' => 'メールアドレス']),
        'email.email'  => trans('validation.email'),
        'password.required'  => trans('validation.required', ['attribute' => 'パスワード']),
        'password.string' => trans('validation.string', ['attribute' => 'パスワード']),
        'password.between'  => trans('validation.between', [
            'attribute' => 'パスワード',
            'min' => User\Password::MIN_LENGTH,
            'max' => User\Password::MAX_LENGTH
        ]),
    ];
}

値オブジェクト

ユーザの属性を定義します。
エンティティの生成時にタイプヒンティングによって誤設定を防ぎます。

値オブジェクトの作成
app/ValueObjects/User/Email.php
class Email implements BaseValueObject
{
    /**
     * @var string
     */
    private $email;
    /**
     * Email constructor.
     * @param string $email
     * @throws \Exception
     */
    public function __construct(string $email)
    {
        if (!$this->isEmail($email)) {
            throw new \Exception(trans('validation.email'));
        }
        $this->email = $email;
    }
    /**
     * @return string
     */
    public function get(): string
    {
        return $this->email;
    }
    /**
     * @param string $email
     * @return bool
     */
    private function isEmail(string $email): bool
    {
        return \Validator::make([$email], ['email'])->passes();
    }
}
app/ValueObjects/User/Name.php
class Name implements BaseValueObject
{
    const MIN_LENGTH = 1;
    const MAX_LENGTH = 255;
    /**
     * @var string
     */
    private $name;
    /**
     * Name constructor.
     * @param string $name
     * @throws \Exception
     */
    public function __construct(string $name)
    {
        if (!$this->isName($name)) {
            throw new \Exception(trans('validation.name'));
        }
        $this->name = $name;
    }
    /**
     * @return string
     */
    public function get(): string
    {
        return $this->name;
    }
    /**
     * @param string $name
     * @return bool
     */
    private function isName(string $name): bool
    {
        $name_between = self::MIN_LENGTH . ',' . self::MAX_LENGTH;
        return \Validator::make([$name], ['between:' . $name_between])->passes();
    }
}
app/ValueObjects/User/Password.php
class Password implements BaseValueObject
{
    const MIN_LENGTH = 4;
    const MAX_LENGTH = 10;
    /** @var string */
    private $password;
    public function __construct(string $password)
    {
        $this->password = $this->hash($password);
    }
    /**
     * @return string
     */
    public function get(): string
    {
        return $this->password;
    }
    /**
     * @param string $password
     * @return bool
     */
    public function isRawPassword(string $password): bool
    {
        $pass_between = self::MIN_LENGTH . ',' . self::MAX_LENGTH;
        return \Validator::make([$password], ['between:' . $pass_between])->passes();
    }
    /**
     * @param string $password
     * @return string
     */
    public function hash(string $password): string
    {
        return self::isRawPassword($password) ? Hash::make($password) : $password;
    }
}

サービス

リポジトリへのアクセスやビジネスロジックのやりとりに利用します。

サービスの作成
app/ValueObjects/User/Email.php
class UserService
{
    /** UserRepository */
    private $user_repos;
    public function __construct()
    {
        $this->user_repos = new UserRepository;
    }

    /**
     * @return Collection
     */
    public function list(): Collection
    {
        return $this->user_repos->list();
    }

    /**
     * @param array $request_params
     * @return bool
     */
    public function store(array $request_params): bool
    {
        try {
            $user = $this->user_repos->new($request_params);
            return $this->user_repos->store($user);
        } catch (\Exception $e) {
            report($e);
            return false;
        }
    }
}

リポジトリ

ファクトリやモデルへのアクセスに利用し、各サービスからのモデルアクセス方法を統一します。

リポジトリの作成
app/Repositories/UserRepository.php
class UserRepository
{
    /**
     * @param array $request_params
     * @return Entities\User
     * @throws \Exception
     */
    public function new(array $request_params): Entities\User
    {
        return (new UserFactory)->make(
            $request_params['name'],
            $request_params['email'],
            $request_params['password']
        );
    }

    /**
     * @return Collection
     */
    public function list(): Collection
    {
        return User::all();
    }

    /**
     * @param Entities\User $user
     * @return bool
     */
    public function store(Entities\User $user): bool
    {
        return (new User([
            'name' => $user->getName(),
            'email' => $user->getEmail(),
            'password' => $user->getPassword()
        ]))->save();
    }
}

ファクトリ

ユーザエンティティを生成するために利用します。
ファクトリがあることで、多少のエンティティの変更はここで吸収できます。

ファクトリの作成
app/Factories/UserFactory.php
class UserFactory
{
    /**
     * @param string $name
     * @param string $email
     * @param string $password
     * @return User
     * @throws \Exception
     */
    public function make(string $name, string $email, string $password): User
    {
        return new User(
            new Name($name),
            new Email($email),
            new Password($password)
        );
    }
}

エンティティ

同一性を識別するモデルになります。
登録や更新をする場合は、ここで生成されたエンティティを使用することで、不正なデータ登録を防ぎます。

エンティティの作成

記載していて気づいたのですが、idの定義も必要ですね。
app/Entities/User.php
class User
{
    /**
     * @var Name
     */
    private $name;
    /**
     * @var Email
     */
    private $email;
    /**
     * @var Password
     */
    private $password;

    /**
     * UserEntity constructor.
     * @param Name $name
     * @param Email $email
     * @param Password $password
     */
    public function __construct(Name $name, Email $email, Password $password)
    {
        $this->name = $name;
        $this->email = $email;
        $this->password = $password;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name->get();
    }

    /**
     * @return string
     */
    public function getEmail(): string
    {
        return $this->email->get();
    }

    /**
     * @return string
     */
    public function getPassword(): string
    {
        return $this->password->get();
    }
}

書いてみた感想

ユーザ登録だけでどんだけコード書くんだ。。と始めは思いましたが、各メソッドのコードはとてもシンプルで改修しやすい作りにできたと思います。

結構考えて実装したのですが、記事を書いている途中でもっといい表現の仕方がありそうだと感じたので、思ったより苦戦してます。

正解がなくおもしろい題材なので、今後も考察を続けていきます。

参考記事

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away