はじめに
株式会社トラストバンクで地域通貨プラットフォーム chiica(チーカ)の開発責任者をしております湊(みなと)(@karura618)です。
今回はLaravelでアーキテクチャ設計を行った際の自分の中のポイントについて紹介させていただきます。
Laravelでアーキテクチャ設計をしていると、一度はこの議論に遭遇すると思います。
- LaravelでもDDDを導入すべきか?
- Repository Patternは必須なのか?
- Eloquentを直接使うのはアンチパターンなのか?
私も、200テーブル以上のLaravelプロジェクトに携わった際、この問題に直面しました。
DDDの理論は非常に魅力的ではあるのですが実際にチームで運用していくと いろんな問題が見えてきます。
- 学習コスト
- 運用コスト
- Laravelとの相性
最終的に自分が携わったプロジェクトでは DDD + Repository Pattern を今回のプロジェクトでは採用しない という判断をしました。
本記事では
- なぜその判断をしたのか
- 実際のアーキテクチャ
- 運用して分かったメリット / デメリット
を実際のコード例とともに解説します。
本記事は特定のサービスやプロダクトの内容を含まず、一般的なアーキテクチャ設計の観点で書かれています。
シリーズ記事
本記事の続編として、Modular Monolithの実装について解説した記事もあります。
👉 LaravelでModular Monolithを実装した話
結論(先にまとめ)
本プロジェクトでは以下の方針を採用しました。
- Repository Patternは採用しない
- Eloquent(Active Record)を全面採用
- データアクセスはUseCase層で行う
- 外部サービスのみInterfaceで抽象化
- ControllerはUseCase呼び出しのみ
結果として
- 開発速度の向上
- 学習コストの削減
- 一貫した実装ルール
を実現することができました。
この記事では、この判断に至った理由と実際のアーキテクチャを解説します。
この記事が役に立つ人
- LaravelでRepository Patternを導入するか悩んでいる
- DDDを導入すべきか迷っている
- 大規模Laravelプロジェクトの構成を知りたい
- 「UseCase / Service / Model」の責務整理を知りたい
- Eloquent(Active Record)の活用方法を知りたい
※補足: 本記事の立場について
本記事はDDDを否定するものではありません。
実際にドメインロジックが非常に複雑なシステムや、長期的にドメインモデルの進化が重要なプロジェクトでは、DDDは非常に有効なアプローチです。
本記事では、
「Laravel + Eloquent が強力に機能する領域」
において、チーム開発と実用性を優先したアーキテクチャ選択の一例として紹介しています。
DDDの価値を理解した上で、プロジェクトの特性・チームの状況・フレームワークの特性を考慮した結果、Active Recordパターンを選択したという設計判断の記録です。
Laravelでよくあるアーキテクチャの悩み
Laravelプロジェクトでは、次のような構成をよく見かけます。
Controller
↓
Service
↓
Repository
↓
Model
一見きれいに見えますが、実際に運用してみると
次のような問題が発生することがあります。
- RepositoryがただのEloquentラッパーになる
- Interface + 実装でファイルが増える
- Eloquentの便利機能が使いにくくなる
- 「どこにロジックを書くか」が曖昧になる
Laravelプロジェクトで 最もよく見かけるアンチパターン が
「なんちゃってRepository」
です。
Repository Patternを導入したはずなのに
- 一部はRepository
- 一部はEloquent直接アクセス
という 中途半端な構成 になっているケースです。
アーキテクチャ選択の背景
プロジェクトの特性
本記事のアーキテクチャは
次のような規模のLaravelプロジェクトで採用しています。
- テーブル数: 200+
- DB接続数: 18
- DBスキーマ: 2
- 外部API: 決済 / ポイント / SMS
- ドメイン: 金融・決済系
- チーム開発: 複数エンジニア
直面した課題
-
学習コストの高さ
- DDDの厳密な実装には、戦術的パターン(Entity、ValueObject、Repository、Aggregate等)の深い理解が必要
- チーム全員が同じレベルで理解するまでの時間がかかる
-
運用コストの増加
- Repository Patternを導入すると、すべてのModelに対してInterface + 実装が必要
- 200テーブル → 400ファイル(Interface 200 + 実装 200)
- テストコードも倍増
-
Laravelのベストプラクティスとの乖離
- LaravelはActive Recordパターンを採用
- EloquentはRelationship、Scope、Accessor、Mutatorなど強力な機能を持つ
- Repository Patternを導入すると、これらの便利機能をそのまま活用しにくくなるケースがある
決断
「DDDとRepository Patternは採用せず、LaravelのベストプラクティスであるActive Recordパターンを全面的に採用する」
Repository Patternを採用しなかった理由
重要な認識: Repository Patternを徹底すると、Eloquentの便利機能を活用しにくくなる
この問題について調べていく中で、いくつかの技術記事や議論を参考にしました。
特に印象的だったのは次の指摘です。
参考: LaravelにRepository Patternを導入するという事 - Zenn
「Repositoryパターンを導入するという事は、**『Eloquentモデル関連の便利な機能を使わない』**という事です。」
「中途半端な導入は避けるべきです。Repositoryパターンを導入するなら、徹底的に導入しましょう。」
実際にプロジェクトで検証してみると、**「Repository Patternを中途半端に導入する危険性」**を痛感しました。
Repository Patternを厳密に導入すると、Eloquentの便利機能をそのまま活用するのが難しくなるケースがあります。特に、Relationship、Scope、Accessor、Mutatorなどの強力な機能を使いづらくなります。
「なんちゃってRepository」の危険性
私が最も避けたかったのは、Repository PatternとEloquent直接アクセスが混在する状態です。
❌ 最悪のパターン: 「なんちゃってRepository」
// ❌ BAD: Repository InterfaceとEloquent直接アクセスが混在
class LoginUseCase
{
public function __construct(
private MemberRepositoryInterface $memberRepository, // ← Repository使用
private LoginHistoryRepositoryInterface $loginHistoryRepository
) {}
public function execute(string $tel, string $password)
{
// Repository経由
$member = $this->memberRepository->findByTel($tel);
// ❌ しかし同じクラス内でEloquent直接アクセス
$program = Program::find($programId); // ← Eloquent直接
// これが「なんちゃってRepository」
}
}
問題点:
- Repository PatternとEloquent直接アクセスが混在
- 「シンプルな取得はModelで直接」という曖昧なルール
- Repositoryを作ってもEloquent依存は解消されない
- メンテナンス性が最悪
中途半端な実装がもたらす混乱
開発者ごとに「シンプル」「複雑」の判断が異なると、以下のような混乱が発生します。
// 開発者Aの判断: これは「シンプル」だから直接アクセス
Member::where('city_id', $cityId)->where('status', 1)->get();
// 開発者Bの判断: 2つの条件は「複雑」だからRepository経由
$this->memberRepository->findByCityAndStatus($cityId, 1);
// 開発者Cの判断: テストで必要だからRepository作成
$this->memberRepository->find($id);
// → 統一性がない = メンテナンス不可能
Repository Patternを徹底的に導入しない理由
Repository Patternを徹底的に導入する場合:
- ✅ すべてのModelにRepository Interface + 実装を作成
- ✅ Eloquent直接アクセスを完全に排除
- ✅ Eloquentの便利機能(Relationship、Scope等)をそのまま活用しにくくなる
- ✅ テスト時はRepository Interfaceをモック
しかし、この方針には以下のデメリットがあります:
- ❌ 学習コストが非常に高い
- ❌ 200テーブル → 400ファイル(Interface + 実装)
- ❌ Eloquentの強力な機能を捨てる
- ❌ 開発速度が大幅に低下
- ❌ チームの技術レベルとのバランスが取れない
結論: Repository Patternは使わない
Eloquentの便利機能を活用したい → Repository Patternは使わない
この明確な決断により、開発者の裁量を排除し、一貫性のあるアーキテクチャを実現しました。
採用したアーキテクチャ: Active Recordパターン
基本方針
Repository Pattern を今回のプロジェクトでは採用しない = Eloquent を全面的に使う
アーキテクチャ図
Eloquent(Active Record)を全面的に採用
Active Recordパターンとは:
- データベースレコード = オブジェクト
- オブジェクト自身がCRUD操作を持つ
- ビジネスロジックもオブジェクトに含む
LaravelのEloquentは、Active Recordパターンの典型的な実装です。
✅ Active Recordパターンの利点
// ✅ シンプルな操作
$member = Member::find($id);
$member->name = 'Updated Name';
$member->save();
// ✅ Eloquent Relationshipの活用
$member = Member::with('points', 'program')->find($id);
// ✅ Eloquent Scopeの活用
$activeMembers = Member::active()->get();
メリット
- ✅ Laravelの強力なORM機能をフル活用
- ✅ Eloquent Relationship、Scope、Accessor、Mutator を活用
- ✅ 開発速度の大幅向上
- ✅ 学習コストの削減(Laravel標準に従う)
- ✅ コード量の削減(Repositoryレイヤー不要)
- ✅ 一貫性のある実装(全てEloquent)
デメリット(認識した上で受け入れる)
- ⚠️ Laravelフレームワークへの強い結合
- ⚠️ テスト時にEloquent Modelをモック or Feature Testで実DB使用
- ⚠️ 将来的なデータソース変更が困難(実際には発生しない)
重要な判断:
- 将来的にLaravelから別のフレームワークに移行する可能性は極めて低い
- 仮に移行する場合でも、ビジネスロジックの大部分はModelに集約されているため、移行は可能
- 実用性を最優先する
実際のプロジェクト構成
本プロジェクトでは以下のようなレイヤー構成を採用しています。
ディレクトリ構造
app/
├── Http/
│ ├── Controllers/
│ │ └── V1/
│ │ └── Api/
│ │ ├── LoginController.php
│ │ └── Member/
│ │ └── PointController.php
│ │
│ ├── Requests/
│ │ └── V1/
│ │ └── Api/
│ │ └── LoginRequest.php
│ │
│ └── Middleware/
│ └── AuthenticateToken.php
│
├── UseCases/
│ ├── LoginUseCase.php
│ └── PointListUseCase.php
│
├── Services/
│ ├── Auth/
│ │ ├── TokenService.php
│ │ └── PasswordHashService.php
│ │
│ ├── Payment/
│ │ └── PaymentCalculationService.php
│ │
│ └── External/
│ ├── Contracts/ # インターフェース(重要)
│ │ ├── PaymentGatewayInterface.php
│ │ └── SmsServiceInterface.php
│ │
│ └── Implementations/ # 外部API連携実装
│ └── SBPayment/
│ └── SBPaymentGatewayService.php
│
├── Models/
│ ├── MemberDB/ # スキーマ1(会員管理DB)
│ │ ├── Member.php
│ │ └── ProvisionalMember.php
│ │
│ └── ServiceDB/ # スキーマ2(サービス管理DB)
│ ├── LoginHistory.php
│ └── Program.php
│
├── DTOs/ # Data Transfer Objects
│ ├── LoginRequest.php
│ ├── LoginResponse.php
│ └── LoginToken.php
│
├── ValueObjects/ # Value Objects
│ ├── Tel.php
│ ├── Password.php
│ └── MemberCode.php
│
├── Exceptions/ # カスタム例外
│ ├── Auth/
│ │ ├── InvalidPasswordException.php
│ │ └── AccountLockedException.php
│ └── Member/
│ └── MemberNotFoundException.php
│
└── Util/ # ユーティリティクラス
└── IpAddress.php
データフロー
HTTP Request
↓
Controller (HTTPハンドリング)
↓
UseCase (ビジネスフローのオーケストレーション)
↓
├─→ Service (ビジネスロジック)
│ ↓
│ └─→ External API (Interfaceを経由)
│
└─→ Model (Eloquent - Active Record)
↓
Database
重要なポイント
-
Controller は UseCase のみを呼び出す
- データアクセスを含むビジネスロジックは UseCase に委譲
-
UseCase は Eloquent 直接使用
- Repository Pattern は使用しない
- トランザクション境界を明確に定義
-
外部API連携のみ Interface 使用
-
Services/External/Contracts/にインターフェース定義 - テスト時にモック可能
-
-
Model にビジネスルールを集約
-
ensureCanLogin(),recordAuthenticationFailure()等 - Active Record パターンの真髄
-
ディレクトリ構成と各層の責務
各層の責務と明確なルール
Controller層
責務:
- HTTPリクエストの受け取り
- バリデーション(FormRequest)
- UseCaseの呼び出し
- HTTPレスポンスの返却
データアクセス:
- ❌ Eloquent Model直接アクセス禁止
- ✅ UseCaseを呼び出す
- ✅ DTOで入出力を整形
実装例:
namespace App\Http\Controllers;
use App\DTOs\LoginRequest as LoginRequestDTO;
use App\Http\Requests\LoginRequest;
use App\UseCases\LoginUseCase;
use Illuminate\Http\JsonResponse;
class LoginController extends Controller
{
public function __construct(
private LoginUseCase $loginUseCase
) {}
public function index(LoginRequest $request): JsonResponse
{
// バリデーション済みデータを取得
$validated = $request->validated();
// リクエストDTOを作成
$requestDTO = new LoginRequestDTO(
tel: $validated['tel'],
password: $validated['password'],
ip: $request->ip(),
userAgent: $request->userAgent()
);
// ユースケース実行
$response = $this->loginUseCase->execute($requestDTO);
return response()->json($response->toArray(), 200);
}
}
重要なポイント:
- Controller は50行以内を目標
- ビジネスロジックは含まない
- UseCaseを経由してビジネス処理を実行
UseCase層
責務:
- ビジネスユースケースの実行フロー
- 複数のServiceの組み合わせ
- トランザクション境界の定義
データアクセス:
- ✅ Eloquent直接使用(データベースアクセス)
- ✅ Interface使用(外部API)
- ✅ Serviceを経由してビジネスロジックを実行
実装例:
namespace App\UseCases;
use App\DTOs\LoginRequest;
use App\DTOs\LoginResponse;
use App\Models\MemberDB\Member;
use App\Models\ServiceDB\LoginHistory;
use App\Services\Auth\TokenService;
use App\ValueObjects\Password;
use App\ValueObjects\Tel;
use Illuminate\Support\Facades\DB;
class LoginUseCase
{
public function __construct(
private TokenService $tokenService
) {}
public function execute(LoginRequest $request): LoginResponse
{
$tel = new Tel($request->tel);
$inputPassword = Password::fromPlainText($request->password);
// ✅ Eloquent直接使用(データベースアクセス)
$member = Member::on('memberdb_r')
->where('tel', $tel->value())
->where('member_type', 1)
->first();
if ($member === null) {
throw new MemberNotFoundException;
}
// ビジネスルールはModel内に
$member->ensureCanLogin();
// パスワード検証(ValueObjectで実装)
if (!$member->authenticate($inputPassword)) {
$this->recordAuthenticationFailure($member);
throw new InvalidPasswordException;
}
// トランザクション開始
DB::connection('memberdb_w')->beginTransaction();
try {
// 認証成功を記録
$this->recordAuthenticationSuccess($member);
// ログイン履歴もEloquent直接
LoginHistory::create([
'member_id' => $member->id,
'login_at' => now(),
]);
DB::connection('memberdb_w')->commit();
} catch (Exception $e) {
DB::connection('memberdb_w')->rollBack();
throw $e;
}
// トークン生成(Serviceに委譲)
$token = $this->tokenService->generateLoginToken($member);
return new LoginResponse(
loginToken: $token->token(),
memberId: $token->memberId(),
expiredAt: $token->expiredAt()->format('Y/m/d H:i:s')
);
}
private function recordAuthenticationSuccess(Member $member): void
{
$member->recordAuthenticationSuccess();
$member->setConnection('memberdb_w');
$member->save();
}
private function recordAuthenticationFailure(Member $member): void
{
$member->recordAuthenticationFailure();
$member->setConnection('memberdb_w');
$member->save();
}
}
重要なポイント:
- UseCaseはビジネスフローのオーケストレーション
- Eloquent直接使用でシンプルに記述
- トランザクション境界を明確に定義
- 複雑なロジックはServiceやModelに委譲
Service層
責務:
- ビジネスロジックの実装
- ドメイン知識のカプセル化
- 複雑な計算やルール
データアクセス:
- ✅ Eloquent直接使用(データベースアクセス)
- ✅ Interface使用(外部API)
- ✅ ビジネスロジックに集中
実装例:
namespace App\Services\Auth;
use App\DTOs\LoginToken;
use App\Models\MemberDB\Member;
use App\ValueObjects\MemberCode;
use Carbon\Carbon;
class TokenService
{
public function __construct(
private readonly string $cryptMethod,
private readonly string $cryptKey,
private readonly int $tokenTtl
) {}
/**
* ログイントークンを生成
*/
public function generateLoginToken(Member $member): LoginToken
{
$memberId = $member->id;
$memberCode = $member->member_code;
$now = Carbon::now();
$expiredAt = $now->timestamp + $this->tokenTtl;
$content = [
'login_at' => $now->format('Y-m-d H:i:s'),
'user_id' => $memberId,
'expired_at' => $expiredAt,
];
// 暗号化処理
$iv = $this->generateIv();
$encrypt = $this->encrypt($content, $iv);
$token = $this->encodeToken($iv, $encrypt);
return new LoginToken(
token: $token,
memberId: $memberId,
memberCode: $memberCode,
expiredAt: DateTimeImmutable::createFromMutable(
Carbon::createFromTimestamp($expiredAt)
)
);
}
// 暗号化処理の詳細は省略...
}
重要なポイント:
- Serviceはビジネスロジックに集中
- 複雑な処理をカプセル化
- データアクセスが必要な場合はEloquent直接使用
Model層(重要)
責務:
- Active Record(Eloquent Model)
- ビジネスルールの実装
- 状態管理と検証
- データベーステーブルとのマッピング
特徴:
- Infrastructure層に属するが、Domain層の役割も担う
- ビジネスロジックをModelに集約
実装例:
namespace App\Models\MemberDB;
use Illuminate\Database\Eloquent\Model;
class Member extends Model
{
// ビジネスルール定数
public const LIMIT_AUTH_FAILED_COUNT = 5;
public const STOP_FLAG_ACTIVE = 0;
public const STOP_FLAG_STOPPING = 1;
protected $table = 'members';
protected $connection = 'memberdb_r';
protected $fillable = [
'tel', 'password', 'member_type', 'stop_flag',
'auth_failed_count', 'account_unlock_date'
];
// ============================================================
// ビジネスロジック(Active Recordパターンの重要な部分)
// ============================================================
/**
* アカウントが有効かチェック
*/
public function isActive(): bool
{
return $this->stop_flag === self::STOP_FLAG_ACTIVE;
}
/**
* アカウントが停止中かチェック
*/
public function isStopped(): bool
{
return $this->stop_flag === self::STOP_FLAG_STOPPING;
}
/**
* アカウントがロックされているかチェック
*/
public function isLocked(): bool
{
if ($this->auth_failed_count < self::LIMIT_AUTH_FAILED_COUNT) {
return false;
}
if ($this->account_unlock_date === null) {
return false;
}
return $this->account_unlock_date > now();
}
/**
* ログイン可能かチェック(ビジネスルール)
*/
public function ensureCanLogin(): void
{
if ($this->isStopped()) {
throw new AccountStoppedException;
}
if ($this->isLocked()) {
throw new AccountLockedException(
$this->account_unlock_date
);
}
}
/**
* パスワード認証
*/
public function authenticate(Password $inputPassword): bool
{
return $inputPassword->verify($this->password);
}
/**
* 認証失敗時の処理(状態変更)
*/
public function recordAuthenticationFailure(): void
{
// 既にロック中だがロック期限が過ぎている場合はリセット
if ($this->auth_failed_count >= self::LIMIT_AUTH_FAILED_COUNT
&& $this->account_unlock_date !== null
&& $this->account_unlock_date <= now()) {
$this->auth_failed_count = 1;
$this->account_unlock_date = null;
} else {
$this->auth_failed_count++;
// リミットに達したらアカウントロック
if ($this->auth_failed_count >= self::LIMIT_AUTH_FAILED_COUNT) {
$this->account_unlock_date = now()->addMinutes(30);
}
}
}
/**
* 認証成功時の処理(状態変更)
*/
public function recordAuthenticationSuccess(): void
{
$this->auth_failed_count = 0;
$this->account_unlock_date = null;
}
}
重要なポイント:
- ビジネスルールをModelに集約
-
ensureCanLogin()のようなドメインルールを実装 - 状態変更メソッド(
recordAuthenticationFailure()等)を提供 - Active Recordパターンの真髄
DTO層
責務:
- 外部境界でのデータ整形
- APIリクエスト/レスポンスのデータ構造
- シリアライゼーション/デシリアライゼーション
特徴:
- 不変(readonly)
- ビジネスロジックを持たない
実装例:
namespace App\DTOs;
final readonly class LoginToken
{
public function __construct(
private string $token,
private int $memberId,
private string $memberCode,
private DateTimeImmutable $expiredAt
) {}
// APIレスポンス用のシリアライゼーション
public function toArray(): array
{
return [
'login_token' => $this->token,
'member_id' => $this->memberId,
'member_code' => $this->memberCode,
'expired_at' => $this->expiredAt->format('Y-m-d H:i:s'),
];
}
// Getter
public function token(): string
{
return $this->token;
}
public function memberId(): int
{
return $this->memberId;
}
public function expiredAt(): DateTimeImmutable
{
return $this->expiredAt;
}
}
ValueObject層
責務:
- 単一の値の表現とその操作
- 値の検証とノーマライゼーション
- 型安全性の提供
特徴:
- 不変(immutable)
- 単一の概念を表す
実装例:
namespace App\ValueObjects;
final class Tel
{
private string $value;
public function __construct(string $tel)
{
// ノーマライゼーション
$normalized = str_replace(['-', ' ', ' '], '', $tel);
// バリデーション
if (!preg_match('/^0[0-9]{9,10}$/', $normalized)) {
throw new InvalidArgumentException('Invalid phone number format');
}
$this->value = $normalized;
}
public function value(): string
{
return $this->value;
}
public function equals(Tel $other): bool
{
return $this->value === $other->value;
}
}
namespace App\ValueObjects;
use Illuminate\Support\Facades\Hash;
final class Password
{
private string $plain;
private function __construct(string $plain)
{
$this->plain = $plain;
}
public static function fromPlainText(string $plainPassword): self
{
if (strlen($plainPassword) < 8) {
throw new InvalidArgumentException('Password too short');
}
return new self($plainPassword);
}
public function verify(string $storedHash): bool
{
return Hash::check($this->plain, $storedHash);
}
}
データアクセスの明確なルール
基本原則: 2択のみ
データアクセスが必要
↓
Q: DBアクセス? 外部API?
↓
├─ DB → Eloquent直接使用
└─ 外部API → Interface使用
開発者の裁量を排除し、明確な2択のみを提供します。
ルール詳細
| データアクセス種別 | 使用方法 |
|---|---|
| シンプルなfind() | ✅ Eloquent直接 |
| 単一テーブルの基本的なwhere() | ✅ Eloquent直接 |
| 複数テーブルJOIN | ✅ Eloquent直接(Relationship使用) |
| 複雑なWHERE条件(何個でも) | ✅ Eloquent直接 |
| サブクエリ | ✅ Eloquent直接 |
| 複雑なクエリの再利用 | ✅ Eloquent Scope使用 |
| 外部API呼び出し | ✅ Interface使用 |
| Repository Pattern | ❌ 使用しない |
実装パターン
パターン1: シンプルなfind()
// ✅ Eloquent直接使用
$member = Member::find($id);
パターン2: 条件検索
// ✅ Eloquent直接使用(何個条件があっても)
$members = Member::where('city_id', $cityId)
->where('status', 1)
->where('deleted_at', null)
->get();
パターン3: 複雑なJOIN
// ✅ Eloquent直接使用(Relationshipを活用)
$member = Member::with([
'points' => function ($query) {
$query->where('program_id', $programId);
},
'program',
'rank'
])->find($memberId);
パターン4: Eloquent Scopeでカプセル化
// Member Model内:
public function scopeWithPointsAndProgram($query, int $programId)
{
return $query->with([
'points' => fn($q) => $q->where('program_id', $programId),
'program',
'rank'
]);
}
// 使用時:
$member = Member::withPointsAndProgram($programId)->find($memberId);
パターン5: 外部API連携(Interface使用)
// ✅ Interface使用(DBではないため)
namespace App\Services\External\Contracts;
interface PaymentGatewayInterface
{
public function processPayment(array $params): array;
public function refundPayment(string $transactionId): array;
}
// 実装
namespace App\Services\External\Implementations\SBPayment;
class SBPaymentGatewayService implements PaymentGatewayInterface
{
public function processPayment(array $params): array
{
// 外部API呼び出し実装...
}
public function refundPayment(string $transactionId): array
{
// 外部API呼び出し実装...
}
}
メリット・デメリット
メリット
1. 開発速度の大幅向上
- Repository Interface + 実装を作成する手間がない
- Eloquentの強力な機能(Relationship、Scope等)をフル活用
- 200テーブルでもファイル数を最小限に抑える
2. 学習コストの削減
- Laravel標準に従うため、公式ドキュメントがそのまま活用できる
- 新しいメンバーがチームに参加しても、学習コストが低い
- DDDの戦術的パターンを深く理解する必要がない
3. 一貫性のある実装
- 「シンプル」「複雑」の主観的判断を排除
- 2択のみ(DB=Eloquent、API=Interface)
- 開発者ごとに実装が異なる問題を回避
4. コード量の削減
- Repository Interface: 0ファイル
- Repository実装: 0ファイル
- Model + UseCase + Serviceのみでシンプル
5. Eloquent機能のフル活用
// Relationship
$member->points; // Eager Loading可能
// Scope
Member::active()->get();
// Accessor
$member->full_name; // getFullNameAttribute()
// Mutator
$member->password = 'plaintext'; // setPasswordAttribute()
デメリット(認識した上で受け入れる)
1. Laravelフレームワークへの強い結合
- Eloquentに強く依存
- 将来的にLaravelから別のフレームワークに移行する場合、コストが高い
判断: 実際にはLaravelから移行する可能性は極めて低い。仮に移行する場合でも、ビジネスロジックはModelに集約されているため、移行は可能。
2. テスト時にEloquent Modelをモック or Feature Testで実DB使用
- Unit TestでEloquent Modelをモックするのは困難
- Feature Testで実DBを使用する必要がある
判断: Feature Testで実DBを使用することで、より実運用に近いテストが可能。テストデータの準備は Factory と Seeder で自動化。
3. 将来的なデータソース変更が困難
- データソースをDBから別のもの(例: NoSQL)に変更する場合、影響が大きい
判断: 金融・決済系でデータソースを変更する可能性は極めて低い。RDBMSが最適。
テスト戦略
Repository Patternを採用しない場合、**「テストが難しくなるのでは?」**という疑問が出るかもしれません。
本プロジェクトでは以下の方針を採用しています。
テスト方針
- UseCase単位でFeature Testを作成
- DatabaseTransactionsを利用してテスト後のロールバック
- Eloquentをそのまま使用(モック不要)
- Factory と Seeder でテストデータを準備
テスト実装例
namespace Tests\Feature;
use App\Models\MemberDB\Member;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class LoginUseCaseTest extends TestCase
{
use DatabaseTransactions;
public function test_login_success(): void
{
// テストデータ作成(Factory使用)
$member = Member::factory()->create([
'tel' => '09000000000',
'password' => Hash::make('password'),
'member_type' => 1,
'stop_flag' => 0,
'auth_failed_count' => 0,
]);
// APIリクエスト実行
$response = $this->postJson('/v1/api/login', [
'tel' => '09000000000',
'password' => 'password',
]);
// アサーション
$response->assertStatus(200);
$response->assertJsonStructure([
'login_token',
'member_id',
'member_code',
'expired_at',
]);
// データベース検証
$this->assertDatabaseHas('login_history', [
'member_id' => $member->id,
]);
}
public function test_login_failure_with_invalid_password(): void
{
$member = Member::factory()->create([
'tel' => '09000000000',
'password' => Hash::make('correct_password'),
]);
$response = $this->postJson('/v1/api/login', [
'tel' => '09000000000',
'password' => 'wrong_password',
]);
$response->assertStatus(404);
// 認証失敗カウントが増加していることを確認
$member->refresh();
$this->assertEquals(1, $member->auth_failed_count);
}
}
テスト戦略のポイント
-
Feature Testを中心に
- UseCaseの入力から出力まで、実際のデータフローをテスト
- 実DBを使用することで、実運用に近い環境でテスト
-
DatabaseTransactions
- テスト終了後に自動ロールバック
- テストデータの残留を防ぐ
-
Factory の活用
- テストデータの作成を自動化
- 必要な属性のみ指定、他はデフォルト値
-
Repository不要のメリット
- Eloquent直接使用のため、モック不要
- テストコードがシンプルに
Laravelのテスト環境の強力さ
Laravelはテスト環境が非常に整備されているため、Repository Patternによる抽象化を行わなくても、十分にテスト可能です。
-
DatabaseTransactionsによる自動ロールバック -
Factoryによる柔軟なテストデータ生成 -
assertDatabaseHas()等の便利なアサーションメソッド - Feature TestとUnit Testの明確な分離
重要な判断: Repository Patternを導入してEloquentをモックするよりも、Feature Testで実DBを使用する方が、実運用に近いテストが可能であり、バグの早期発見につながる。
この設計が向いているプロジェクト
このアーキテクチャは、すべてのLaravelプロジェクトに
最適というわけではありません。
特に次のようなプロジェクトでは有効だと考えています。
- Laravelをメインフレームワークとして長期運用する
- チーム開発(複数エンジニア)
- テーブル数が多い(100〜200以上)
- Eloquentの機能を最大限活用したい
どんなプロジェクトならRepository Patternが向いているか
ここまでRepository Patternを採用しなかった理由を書きましたが、
すべてのプロジェクトで不要というわけではありません。
Repository Patternが有効なケースもあります。
Repository Pattern が適しているケース
-
ORMを抽象化したい場合
- 将来的にEloquent以外のORMに変更する可能性がある
- 複数のORMを併用する必要がある
-
ドメインロジックが非常に複雑
- ドメインモデル中心の設計を重視
- フレームワークに依存しない設計が必要
-
マイクロサービス間でDomainを共有する
- 複数のサービスで同じDomain層を使用
- データアクセス層を完全に抽象化したい
-
DDDを厳密に実装する
- 戦術的パターンを徹底的に適用
- チーム全体がDDDの理解を深めている
重要なのは
「フレームワークの思想」と「プロジェクトの特性」を合わせること
だと考えています。
Laravelは Active Record前提のフレームワーク なので、
フレームワークの思想に従うことが開発効率の向上につながるケースが多いです。
ただし、プロジェクトの特性によってはRepository Patternの方が適している場合もあります。
アーキテクチャは「正解」ではなく「選択」
という点が最も重要です。
実際に運用してみた結果
この構成で数年運用していますが、以下のメリットを感じています。
-
新規メンバーのキャッチアップが早い
- Laravel公式ドキュメントがそのまま活用できる
- 学習資料が豊富
-
コードの責務が比較的明確
- Controller → UseCase → Service/Model の流れが一貫
- データアクセスルールが明確(DB=Eloquent、API=Interface)
-
Eloquentの便利機能を最大限活用できる
- Relationship、Scope、Accessor、Mutatorを駆使
- N+1問題もEager Loadingで解決しやすい
-
不必要な抽象化が減り開発速度が向上
- Repository Interface + 実装の作成工数がゼロ
- ファイル数が半分に(200テーブルで400ファイル削減)
Laravelは Active Record前提のフレームワーク なので、
フレームワークの思想に従うことの重要性を改めて感じました。
まとめ
本プロジェクトでの重要な決断
本プロジェクトでは以下の理由から、DDD + Repository Pattern を今回のプロジェクトでは採用しませんでした。
-
チーム全体の学習コスト
- DDDの厳密な実装には深い理解が必要
- チームの技術レベルとのバランスを優先
-
Eloquentの強力な機能
- Relationship、Scope、Accessor、Mutator をフル活用したい
- Repository Patternを導入すると、これらをそのまま活用しにくくなるケースがある
-
Laravelの設計思想との整合性
- LaravelはActive Recordパターンを採用
- フレームワークのベストプラクティスに従う
-
実装と運用のシンプルさ
- 200テーブル → 200ファイル(Repositoryなし)
- 開発速度の大幅向上
その代わりに採用した方針
-
UseCase層を明確にする
- ビジネスフローのオーケストレーションを担当
- トランザクション境界を明確に定義
-
Eloquentの機能を最大限活用する
- Relationship、Scope、Accessor、Mutator を駆使
- データアクセスの一貫性を保つ
-
一貫したルールを徹底する
- 2択のみ:DB=Eloquent直接、API=Interface
- 開発者の裁量を排除
-
Model にビジネスルールを集約する
-
ensureCanLogin()のようなドメインルールを実装 - Active Recordパターンの真髄
-
Laravelのアーキテクチャには様々なアプローチがあります
DDD
Repository Pattern
Active Record
どれが正しいというより、
「チームとプロダクトに合うかどうか」
が最も重要だと思っています。
本記事がアーキテクチャ設計を行う上でご参考になりますと幸いです。
参考資料
- LaravelにRepository Patternを導入するという事 - Zenn
- Laravel公式ドキュメント - Eloquent ORM
- Laravel公式ドキュメント - Testing
- Active Record Pattern - Wikipedia
📚 続編記事
本記事では「Repository Patternを採用しない」という判断について解説しましたが、
実はもう一つ重要な設計判断がありました。
200テーブルをモノリスで管理する秘訣 - Modular Monolith(モジュラーモノリス)
続編では、以下の内容を解説しています。
- マイクロサービスにせず200テーブルを管理する方法
- Bounded Contextの実現方法
- スキーマ分離による明確な境界
- 18DB接続の管理術
👉 LaravelでModular Monolithを実装した話 - マイクロサービスにせず200テーブルを管理する設計
エンジニア募集
弊社では絶賛エンジニア募集中です!
気になった方、是非お気軽に Wantedly からご連絡ください!