【Laravel】Model を内包した Entity について
「Laravel でクリーンアーキテクチャやりたいっす!」みたいな文脈では,Eloquent Model の扱いは難しい.
Eloquent Model は,それ自身が 1 レコードの情報を持ついわば「Entity」のような役割もするし,
code:php
$user = User::query()->where('name', 'fuwasegu')->first();
みたいな感じで,Model 起点でクエリを発行することができる「Repository」の役割もする.
これは明らかに責務分割の考え方からは離れていくので,それを解消する案として
Repository だけを作る案
中身は Model から QueryBuilder を作る or DB::select() などでデータを取得
Repository が返すのは Eloquent Model
ただし,呼び出し側では Entity としての使い方以外はしないように明確にルールを定める(人間が意識する必要がある)
Entity だけを作る案
UseCase なので Eloquent Model を使って取得したデータをオブジェクトに詰め替えて使う
Entity ⇔ Model の変換があるので UseCase が肥大化しそうな気もする
Repository + Entity を作る案
上記の組み合わせ
Repository が返すのは Entity
Model は Query Builder の呼び出しにしか基本的には使わない(Repository の中でしかでてこない)
などなど,色々案はあるわけだけど,Entity を作るとなったときにも,どういうふうに作るかというのは,考えないといけないポイントである.
ここでは,Eloquent Model を内包する Entity について考えたい.
基本的には,以下の記事で解説した
色んな人にこれを勧めているけど.「リレーションどうしたらいいんですか?」と良く聞かれるので補足を雑にまとめる.
例えば,こんな Entity Relationship を考える
User ユーザー
id プライマリキー
name 名前
Post 投稿記事
id プライマリキー
author_id 投稿者(User)の外部キー
title タイトル
content 本文
Comment コメント
id プライマリキー
commenter_id コメントの投稿者(User)の外部キー
post_id コメント対象の記事(Post)の外部キー
content 本文
これをリレーション含めて Model にするとこうなる($casts などは省略)
code:php
/**
* @property string $id
* @property string $name
*
* @property-read Collection<Post::class> $posts
* @property-read Collection<Comment::class> $comments
*/
class User extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class, 'commenter_id');
}
public function toEntity(): UserEntity
{
return new UserEntity($this);
}
}
code:php
/**
* @property string $id
* @property string $author_id
* @property string $title
* @property string $content
*
* @property-read User $author
* @property-read Collection<Comment::class> $comments
*/
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function toEntity(): PostEntity
{
return new PostEntity($this);
}
}
code:php
/**
* @property string $id
* @property string $commenter_id
* @property string $post_id
* @property string $content
*
* @property-read User $commenter
* @property-read Post $post
*/
class Comment extends Model
{
public function commenter(): BelongsTo
{
return $this->belongsTo(User::class, 'commenter_id');
}
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function toEntity(): CommentEntity
{
return new CommentEntity($this);
}
}
ここから,Model を内包する Entity を作るとこうなる
code:php
class UserEntity
{
public function __construct(
private User $model
){
assert($model->exists);
}
public function id(): string
{
return $this->model->id;
}
public function name(): string
{
return $this->model->name;
}
public function posts(): Collection
{
return $this->posts->toBase()->map(fn (Post $model) => $model->toEntity());
}
}
code:php
class PostEntity
{
public function __construct(
private Post $model
){
assert($model->exists);
}
public function id(): string
{
return $this->model->id;
}
public function authorId(): string
{
return $this->model->author_id;
}
public function title(): string
{
return $this->model->title;
}
public function content(): string
{
return $this->model->conetnt;
}
public function author(): UserEntity
{
return $this->model->author->toEntity();
}
}
code:php
class CommentEntity
{
public function __construct(
private Comment $model
){
assert($model->exists);
}
public function id(): string
{
return $this->model->id;
}
public function commenterId(): string
{
return $this->model->commenter_id;
}
public function postId(): string
{
return $this->model->post_id;
}
public function content(): string
{
return $this->model->content;
}
public function commenter(): UserEntity
{
return $this->model->commenter->toEntity();
}
public function post(): PostEntity
{
return $this->model->post->toEntity();
}
}
ちょっと書くの疲れたけど,リレーションも含めるとこんな感じになる.
Model に toEntity() を実装しておくことで,簡単に Entity への詰替が可能.
このモデルを実際に使ってちょっとした UseCase を実装してみると,こんな感じになる.
型がわかりやすいようにコメントを書いておく
code:php
// とある Post にコメントしたユーザーを全員取得する UseCase
class GetCommenterByPostIdAction
{
public function __invoke(string $postId): array
{
// @var PostEntity $post
$post = Post::query()->findOrFail($postId)->toEntity();
// @var Collection<CommentEntity::class> $comments
$comments = $post->comments();
// @var Collection<UserEntity::class> $commenters
$commenters = $comments
// これだと多分 N+1 になる
->map(fn (CommentEntity $comment) => $comment->commenter())
->unique(fn (UserEntity $commenter) => $commenter->id());
return $commenters;
}
}
こんな感じで,リレーション先も取ってこれる
ただ,コメントにも書いた通りあんまり頭使わず書いてると簡単に N+1 問題が発生してしまう.
ただし,EloquentModel は with で予めロードしておくので,Model を内包したEntity はちゃんとよしなにできるはず.
code:php
// とある Post にコメントしたユーザーを全員取得する UseCase
class GetCommenterByPostIdAction
{
public function __invoke(string $postId): array
{
// @var PostEntity $post
$post = Post::query()
// 予め with でロードしとく
->findOrFail($postId)
->toEntity();
// @var Collection<CommentEntity::class> $comments
$comments = $post->comments();
// @var Collection<UserEntity::class> $commenters
$commenters = $comments
// with で予め読み込んでるのでサイドクエリが吐かれることは無い
->map(fn (CommentEntity $comment) => $comment->commenter())
->unique(fn (UserEntity $commenter) => $commenter->id());
return $commenters;
}
}
結構いい使い心地だと思う.