はじめに
株式会社トラストバンクで地域通貨プラットフォーム chiica(チーカ)の開発責任者をしております湊(みなと)(@karura618)です。
今回は、 前回の記事 の続きとしてLaravelでのアーキテクチャ設計時に Modular Monolith を実装した話をさせていただきます。
マイクロサービスアーキテクチャが注目される一方で、
その複雑さに悩むチームも増えています。
- サービス間通信のオーバーヘッド
- 分散トランザクションの複雑さ
- デプロイ・運用コストの増加
- チームの技術レベルとのギャップ
しかし、単純なモノリスに戻ると
今度は 以下の問題 が発生します。
- 200テーブルが1つのディレクトリに混在
- ドメイン境界が曖昧
- コード変更の影響範囲が不明確
そこで今回採用したのが
Modular Monolith(モジュラーモノリス)
です。
この記事では、
- Modular Monolithとは何か
- Laravelでどう実装したか
- 200テーブル規模での運用結果
を実際のコード例とともに解説します。
本記事は以下の記事の続編です。
LaravelでRepository Patternを採用しなかった理由
本記事は特定のサービスやプロダクトの内容を含まず、一般的なアーキテクチャ設計の観点で書かれています。
結論(先にまとめ)
本プロジェクトでは以下のアーキテクチャを採用しました。
- 単一のLaravelアプリケーション(モノリス)
- スキーマ分離によるBounded Context実現
- 18DB接続の明確な管理
- Read/Write分離(CQRS的アプローチ)
- マイクロサービスへの移行可能性を保持
結果として
- マイクロサービスの複雑さを回避
- コンテキスト境界を明確化
- デプロイ・運用コストの削減
- 200テーブルでも破綻しない設計
を実現することができました。
この記事が役に立つ人
- マイクロサービスの複雑さに悩んでいる
- モノリスの限界を感じている
- 大規模Laravelプロジェクトの設計を知りたい
- Bounded Contextの実装方法を知りたい
- Modular Monolithについて知りたい
Modular Monolithとは何か
定義
Modular Monolith(モジュラーモノリス) とは、
単一のアプリケーション(モノリス)だが
内部的に明確なモジュール境界を持つアーキテクチャ
です。
3つのアーキテクチャの比較
従来型モノリス
app/
└── Models/
├── User.php
├── Member.php
├── Post.php
├── Comment.php
├── Product.php
├── Order.php
├── Payment.php
├── Point.php
├── Program.php
└── ... (200個全部フラット)
問題点:
- すべてが1つのディレクトリに混在
- ドメイン境界が不明確
- コード変更の影響範囲が分からない
- 200テーブル規模では破綻する
マイクロサービス
member-service/
└── Models/
├── Member.php
└── ProvisionalMember.php
content-service/
└── Models/
├── Post.php
└── Comment.php
payment-service/
└── Models/
├── Payment.php
└── Order.php
メリット:
- ✅ コンテキスト境界が明確
- ✅ 独立したデプロイが可能
- ✅ 技術スタックの自由度
デメリット:
- ❌ サービス間通信のオーバーヘッド
- ❌ 分散トランザクションの複雑さ
- ❌ デプロイ・運用コストの増加
- ❌ チームの学習コスト
Modular Monolith(私たちの選択)
app/
└── Models/
├── MemberDB/ # 会員コンテキスト
│ ├── Member.php
│ └── ProvisionalMember.php
│
└── ServiceDB/ # サービスコンテキスト
├── LoginHistory.php
├── Program.php
└── Point.php
メリット:
- ✅ コンテキスト境界が明確(スキーマ分離)
- ✅ 単一のデプロイ(シンプル)
- ✅ サービス間通信なし(高速)
- ✅ トランザクション管理が簡単
- ✅ マイクロサービスへの移行も可能
デメリット:
- ⚠️ 完全な独立デプロイは不可
- ⚠️ 技術スタックは統一
なぜModular Monolithを選んだか
プロジェクトの特性
本記事のアーキテクチャは
次のような規模のLaravelプロジェクトで採用しています。
- テーブル数: 200+
- DB接続数: 18
- DBスキーマ: 2つの主要スキーマ + その他
- 外部API: 決済 / ポイント / SMS
- ドメイン: 金融・決済系
- チーム開発: 複数エンジニア
マイクロサービスを選ばなかった理由
1. 分散トランザクションの複雑さ
金融・決済系では、
会員情報の更新 + ポイント付与
のようなアトミックな操作が頻繁に発生します。
マイクロサービスでは、
// ❌ マイクロサービス: 分散トランザクション
try {
// Service 1: 会員サービス
$memberService->updateMember($memberId, $data);
// Service 2: ポイントサービス
$pointService->grantPoints($memberId, $points);
// → どちらかが失敗したら?
// → Sagaパターン? 2フェーズコミット?
} catch (Exception $e) {
// ロールバックの実装が複雑...
}
Modular Monolithでは、
// ✅ Modular Monolith: 単一トランザクション
DB::beginTransaction();
try {
// 会員情報更新(MemberDBスキーマ)
$member = Member::on('memberdb_w')->find($memberId);
$member->update($data);
// ポイント付与(ServiceDBスキーマ)
MemberPoint::on('servicedb_w')->create([
'member_id' => $memberId,
'points' => $points,
]);
DB::commit();
} catch (Exception $e) {
DB::rollBack();
throw $e;
}
判断: トランザクション管理のシンプルさを優先
2. サービス間通信のオーバーヘッド
マイクロサービスでは、
会員情報取得 → HTTP/gRPC → レイテンシ増加
ポイント取得 → HTTP/gRPC → レイテンシ増加
プログラム取得 → HTTP/gRPC → レイテンシ増加
Modular Monolithでは、
// 単一プロセス内で完結
$member = Member::find($memberId);
$points = $member->points; // Relationship
$program = $points->program; // Eager Loading
判断: レスポンスタイムを優先
3. デプロイ・運用コストの増加
マイクロサービスでは、
会員サービス: デプロイ、監視、ログ集約
ポイントサービス: デプロイ、監視、ログ集約
決済サービス: デプロイ、監視、ログ集約
→ 運用コスト 3倍
Modular Monolithでは、
単一アプリケーション: デプロイ、監視、ログ集約
→ 運用コスト 1倍
判断: 運用コストを最小化
従来型モノリスを選ばなかった理由
200テーブルを1つのディレクトリに配置すると、
app/Models/
├── User.php
├── Member.php
├── ... (200個フラット)
問題:
- どのModelがどのドメインに属するか不明確
- コード変更の影響範囲が分からない
- チーム開発でコンフリクトが頻発
判断: ドメイン境界を明確にする必要がある
LaravelでBounded Contextを実現する
Bounded Contextとは
Bounded Context(境界づけられたコンテキスト) は、
DDDの最も重要な概念の1つです。
簡単に言うと、
「意味の境界」
です。
例えば、「会員(Member)」という言葉は、
会員管理コンテキスト:
→ 電話番号、メールアドレス、認証情報
サービス管理コンテキスト:
→ ポイント、プログラム参加履歴、ランク
のように、コンテキストによって意味が異なります。
スキーマ分離によるBounded Context実現
本プロジェクトでは、
データベーススキーマを分離することで、Bounded Contextを実現しています。
主要な2つのコンテキスト
MemberDB(会員管理コンテキスト)
↓
会員情報、認証、個人情報
ServiceDB(サービス管理コンテキスト)
↓
ポイント、プログラム、ログ、通知
ディレクトリ構成
app/
├── Models/
│ ├── MemberDB/ # 会員管理コンテキスト
│ │ ├── Member.php
│ │ ├── ProvisionalMember.php
│ │ ├── MemberTel.php
│ │ └── MemberNickname.php
│ │
│ └── ServiceDB/ # サービス管理コンテキスト
│ ├── MemberLoginLog.php
│ ├── MemberPoint.php
│ ├── MemberPointLog.php
│ ├── Program.php
│ ├── ProgramDistribution.php
│ ├── City.php
│ └── CityGroup.php
│
├── UseCases/
│ ├── LoginUseCase.php # 会員コンテキスト使用
│ ├── RegisterMainMemberUseCase.php # 会員コンテキスト使用
│ └── PointListUseCase.php # 両コンテキスト使用
│
└── Services/
├── Auth/
│ ├── TokenService.php # 会員コンテキスト使用
│ └── MemberAuthService.php # 会員コンテキスト使用
│
└── Point/
└── PointService.php # サービスコンテキスト使用
Models配置の重要性
従来型モノリス(悪い例):
// ❌ BAD: すべて同じディレクトリ
app/Models/
├── Member.php
├── ProvisionalMember.php
├── MemberLoginLog.php
├── MemberPoint.php
├── Program.php
└── ... (200個フラット)
問題:
- どのModelがどのスキーマに属するか分からない
- コンテキスト境界が不明確
Modular Monolith(良い例):
// ✅ GOOD: スキーマ別にディレクトリ分離
app/Models/
├── MemberDB/
│ ├── Member.php
│ └── ProvisionalMember.php
│
└── ServiceDB/
├── MemberLoginLog.php
├── MemberPoint.php
└── Program.php
メリット:
- スキーマが一目で分かる
- コンテキスト境界が明確
- チーム開発でコンフリクトしにくい
18DB接続の管理
接続構成
本プロジェクトでは、18のDB接続を管理しています。
config/database.php:
// 会員管理コンテキスト(MemberDBスキーマ)
'memberdb_r' => [...], // Read専用
'memberdb_w' => [...], // Write専用
// サービス管理コンテキスト(ServiceDBスキーマ)
'servicedb_r' => [...], // Read専用
'servicedb_w' => [...], // Write専用
// その他14接続
...
Read/Write分離
各コンテキストでRead/Write分離を実装しています。
// ✅ Read操作: Read専用接続
$member = Member::on('memberdb_r')
->where('tel', $tel)
->first();
// ✅ Write操作: Write専用接続
$member->setConnection('memberdb_w');
$member->save();
メリット:
- レプリケーション遅延の考慮
- Read負荷分散
- Write性能の確保
Model実装例
MemberDB(会員管理コンテキスト)のModel
namespace App\Models\MemberDB;
use Illuminate\Database\Eloquent\Model;
class Member extends Model
{
// ============================================================
// コンテキスト: 会員管理(MemberDB)
// ============================================================
protected $table = 'members';
// デフォルトはRead専用接続
protected $connection = 'memberdb_r';
public const MEMBER_TYPE_APP = 1;
public const MEMBER_TYPE_CARD = 2;
protected $fillable = [
'tel',
'member_code',
'password',
'member_type',
'stop_flag',
'auth_failed_count',
'account_unlock_date',
];
// ============================================================
// ビジネスルール(会員コンテキストのドメインロジック)
// ============================================================
/**
* ログイン可能かチェック
*/
public function ensureCanLogin(): void
{
if ($this->isStopped()) {
throw new AccountStoppedException;
}
if ($this->isLocked()) {
throw new AccountLockedException(
$this->account_unlock_date
);
}
}
/**
* 認証失敗を記録
*/
public function recordAuthenticationFailure(int $lockMinutes): 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($lockMinutes);
}
}
}
}
ServiceDB(サービス管理コンテキスト)のModel
namespace App\Models\ServiceDB;
use Illuminate\Database\Eloquent\Model;
class MemberPoint extends Model
{
// ============================================================
// コンテキスト: コンテンツ管理(ServiceDB)
// ============================================================
protected $table = 'member_points';
// デフォルトはRead専用接続
protected $connection = 'servicedb_r';
protected $fillable = [
'member_id',
'city_id',
'point',
'program_id',
];
// ============================================================
// Relationship(サービスコンテキスト内)
// ============================================================
public function program()
{
return $this->belongsTo(Program::class, 'program_id');
}
public function city()
{
return $this->belongsTo(City::class, 'city_id');
}
}
重要なポイント:
-
namespaceでコンテキストを明確化 -
$connectionでデフォルト接続を指定 - ビジネスルールは各コンテキストに集約
UseCaseでのコンテキスト利用
単一コンテキストの例: LoginUseCase
namespace App\UseCases;
use App\DTOs\LoginRequest;
use App\DTOs\LoginResponse;
use App\Models\MemberDB\Member;
use App\Models\ServiceDB\MemberLoginLog;
use App\Services\Auth\TokenService;
use Illuminate\Support\Facades\DB;
class LoginUseCase
{
public function __construct(
private TokenService $tokenService
) {}
public function execute(LoginRequest $request): LoginResponse
{
// ============================================================
// 会員管理コンテキスト(MemberDB)の操作
// ============================================================
// Read操作
$member = Member::on('memberdb_r')
->where('tel', $request->tel)
->where('member_type', Member::MEMBER_TYPE_APP)
->first();
if ($member === null) {
throw new MemberNotFoundException;
}
// ビジネスルール実行
$member->ensureCanLogin();
if (!$member->authenticate($request->password)) {
$this->recordAuthenticationFailure($member);
throw new InvalidPasswordException;
}
// ============================================================
// トランザクション開始(複数コンテキストにまたがる)
// ============================================================
DB::connection('memberdb_w')->beginTransaction();
DB::connection('servicedb_w')->beginTransaction();
try {
// Write操作(会員コンテキスト)
$member->recordAuthenticationSuccess();
$member->setConnection('memberdb_w');
$member->save();
// Write操作(サービスコンテキスト)
MemberLoginLog::on('servicedb_w')->create([
'member_id' => $member->id,
'logined' => now(),
]);
DB::connection('memberdb_w')->commit();
DB::connection('servicedb_w')->commit();
} catch (Exception $e) {
DB::connection('memberdb_w')->rollBack();
DB::connection('servicedb_w')->rollBack();
throw $e;
}
// トークン生成
$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 recordAuthenticationFailure(Member $member): void
{
DB::connection('memberdb_w')->beginTransaction();
try {
$member->recordAuthenticationFailure(30);
$member->setConnection('memberdb_w');
$member->save();
DB::connection('memberdb_w')->commit();
} catch (Exception $e) {
DB::connection('memberdb_w')->rollBack();
throw $e;
}
}
}
重要なポイント:
- UseCaseがコンテキスト間の境界を超える唯一の場所
- トランザクションは明示的に管理
- Read/Write分離を徹底
コンテキスト間の通信
原則: UseCase経由でのみ通信
Modular Monolithでは、
コンテキスト間の直接アクセスを禁止
しています。
❌ BAD: Model間で直接Relationship
// ❌ BAD: コンテキスト境界を超えるRelationship
namespace App\Models\ServiceDB;
class MemberPoint extends Model
{
// ❌ ServiceDB → MemberDB への直接参照
public function member()
{
return $this->belongsTo(
\App\Models\MemberDB\Member::class,
'member_id'
);
}
}
問題:
- コンテキスト間の結合度が高まる
- マイクロサービス化が困難になる
✅ GOOD: UseCase経由で結合
// ✅ GOOD: UseCase層で結合
namespace App\UseCases;
class PointListUseCase
{
public function execute(PointListRequest $request): PointListResponse
{
// 会員コンテキストから会員情報取得
$member = Member::on('memberdb_r')
->find($request->memberId);
// サービスコンテキストからポイント情報取得
$points = MemberPoint::on('servicedb_r')
->where('member_id', $request->memberId)
->get();
// UseCase層で結合
return new PointListResponse(
member: $member,
points: $points
);
}
}
メリット:
- コンテキスト境界が明確
- 将来的なマイクロサービス化が容易
トランザクション境界の明確化
原則: UseCaseがトランザクション境界
class PaymentUseCase
{
public function execute(PaymentRequest $request): PaymentResponse
{
// ============================================================
// トランザクション開始(複数コンテキスト)
// ============================================================
DB::connection('memberdb_w')->beginTransaction();
DB::connection('servicedb_w')->beginTransaction();
try {
// 会員情報更新(MemberDBコンテキスト)
$member = Member::on('memberdb_w')
->find($request->memberId);
$member->updateLastPaymentDate();
$member->save();
// ポイント付与(ServiceDBコンテキスト)
MemberPoint::on('servicedb_w')->create([
'member_id' => $member->id,
'point' => $request->points,
]);
// ログ記録(ServiceDBコンテキスト)
PurchaseLog::on('servicedb_w')->create([
'member_id' => $member->id,
'amount' => $request->amount,
]);
DB::connection('memberdb_w')->commit();
DB::connection('servicedb_w')->commit();
} catch (Exception $e) {
DB::connection('memberdb_w')->rollBack();
DB::connection('servicedb_w')->rollBack();
throw $e;
}
return new PaymentResponse(...);
}
}
重要なポイント:
- UseCaseがトランザクション境界を定義
- 複数コンテキストにまたがる操作も単一トランザクションで管理
- マイクロサービスではSagaパターンが必要になる部分
Mermaid図: アーキテクチャ全体像
メリット・デメリット
メリット
1. マイクロサービスの複雑さを回避
- ✅ サービス間通信なし(プロセス内メモリアクセス)
- ✅ 分散トランザクション不要(単一DB複数スキーマ)
- ✅ デプロイが簡単(単一アプリケーション)
- ✅ 運用コストが低い(監視、ログ集約が1つ)
2. 従来型モノリスより明確な境界
- ✅ Bounded Contextが明確(スキーマ分離)
- ✅ ドメイン境界が分かりやすい
- ✅ チーム分担が明確(コンテキスト単位)
3. Eloquentの強力な機能を活用
- ✅ Relationship、Scope、Accessor、Mutator
- ✅ Eager Loading(N+1問題対策)
- ✅ Query Builder
4. マイクロサービスへの移行可能性
- ✅ コンテキスト境界が明確なので分割しやすい
- ✅ UseCase経由でのみ通信(疎結合)
5. 開発速度の向上
- ✅ サービス間通信の実装不要
- ✅ デプロイが簡単
- ✅ ローカル開発環境が軽量
デメリット(認識した上で受け入れる)
1. 完全な独立デプロイは不可
- ⚠️ 単一アプリケーションなので、部分的なデプロイは不可
- ⚠️ 全体をデプロイする必要がある
判断: デプロイ頻度が低く、ダウンタイムも許容範囲内
2. 技術スタックは統一
- ⚠️ すべてLaravel + PHP
- ⚠️ コンテキストごとに技術を変えることは不可
判断: Laravelで十分な機能を実現できる
3. スケーラビリティの制約
- ⚠️ 水平スケーリングは可能だが、コンテキスト単位でのスケーリングは不可
- ⚠️ すべてのコンテキストが同時にスケールする
判断: 現在のトラフィックでは問題ない
4. チーム分割の制約
- ⚠️ 完全な独立開発は困難
- ⚠️ 同じリポジトリ、同じデプロイサイクル
判断: チームサイズが大きくないので問題ない
マイクロサービスへの移行可能性
現在のアーキテクチャ: Modular Monolith
Laravel Application
├─ MemberDBコンテキスト
│ └─ Models, Services, UseCases
└─ ServiceDBコンテキスト
└─ Models, Services, UseCases
将来的なマイクロサービス化
もしトラフィックが増大し、
マイクロサービス化が必要になった場合、
コンテキスト境界が明確なので、比較的容易に分割できます。
Member Service (Laravel)
├─ MemberDBコンテキスト
└─ Models, Services, UseCases
Content Service (Laravel)
├─ ServiceDBコンテキスト
└─ Models, Services, UseCases
移行手順:
-
API境界の定義
- UseCase層をREST APIに変換
- コンテキスト間の通信をHTTP/gRPCに変更
-
トランザクション境界の再設計
- 単一トランザクション → Sagaパターン
- 補償トランザクションの実装
-
データベース分離
- スキーマ分離 → 物理的なDB分離
- レプリケーション設定
-
デプロイ環境の分離
- 単一アプリケーション → 複数サービス
- サービスディスカバリーの導入
重要なポイント:
- コンテキスト境界が明確なので、分割しやすい
- UseCase経由でしか通信していないので、API化が容易
実際に運用してみた結果
この構成で数年運用していますが、以下のメリットを実感しています。
開発面
-
コンテキスト境界が明確で開発しやすい
- どのコンテキストに属するか一目で分かる
- 新しいメンバーでも迷わない
-
チーム分担が明確
- 会員管理チーム → MemberDBコンテキスト
- コンテンツチーム → ServiceDBコンテキスト
- コンフリクトが減る
-
Eloquentの便利機能を活用できる
- Relationship、Scope、Accessor、Mutatorを駆使
- N+1問題もEager Loadingで解決
運用面
-
デプロイが簡単
- 単一アプリケーション
- デプロイ時間: 約5分
- ダウンタイム: ほぼゼロ(Blue-Green Deployment)
-
監視・ログが集約されている
- 1つのアプリケーションを監視すればOK
- ログ集約が簡単
-
トラブルシューティングが容易
- ログを1箇所で確認
- トランザクション境界が明確
パフォーマンス面
-
レスポンスタイムが速い
- サービス間通信なし
- プロセス内メモリアクセス
- 平均レスポンスタイム: 50ms以下
-
トランザクション管理が確実
- 単一DBトランザクション
- ロールバックが確実
この設計が向いているプロジェクト
このアーキテクチャは、すべてのプロジェクトに最適というわけではありません。
特に次のようなプロジェクトでは有効だと考えています。
Modular Monolithが向いているケース
- 中規模〜大規模のモノリス(100〜500テーブル)
- ドメイン境界が明確だが、マイクロサービスほど分割する必要はない
- トランザクション整合性が重要(金融・決済系)
- チームサイズが中規模(5〜20人程度)
- デプロイ・運用コストを抑えたい
- 将来的なマイクロサービス化の可能性を残したい
マイクロサービスが向いているケース
- 超大規模プロジェクト(1000テーブル以上)
- 完全に独立したチーム(30人以上)
- コンテキストごとに異なる技術スタック
- コンテキストごとに異なるスケーリング要件
- 結果整合性で問題ない
従来型モノリスが向いているケース
- 小規模プロジェクト(50テーブル以下)
- ドメイン境界が不明確
- プロトタイプ・MVP
まとめ
Modular Monolithの本質
本プロジェクトでは、
マイクロサービスの「コンテキスト境界の明確さ」と、モノリスの「シンプルさ」の両方を実現する
ために、Modular Monolithを採用しました。
重要な判断:
-
スキーマ分離でBounded Contextを実現
- Models配置でコンテキストを明確化
- MemberDB / ServiceDB の2つの主要コンテキスト
-
UseCase層でトランザクション境界を定義
- コンテキスト間通信はUseCase経由のみ
- 単一トランザクションで整合性を保証
-
Read/Write分離で性能を確保
- 18DB接続の明確な管理
- レプリケーション遅延を考慮
-
マイクロサービス化の可能性を保持
- コンテキスト境界が明確
- UseCase経由でのみ通信
200テーブルでも破綻しない理由
従来型モノリス:
→ 200テーブルが1つのディレクトリに混在
→ ドメイン境界が不明確
→ 破綻
Modular Monolith:
→ スキーマ分離でBounded Context実現
→ ドメイン境界が明確
→ 200テーブルでも管理可能
アーキテクチャは「正解」ではなく「選択」
マイクロサービス、Modular Monolith、従来型モノリス、
どれが正しいというより、
「プロジェクトの特性・チームの状況・技術的要件」に合うかどうか
が最も重要だと考えています。
私たちのプロジェクトでは、
- 金融・決済系(トランザクション整合性が重要)
- 中規模チーム
- 運用コストを抑えたい
という特性から、Modular Monolithが最適でした。
参考資料
- Modular Monolith: A Primer
- Domain-Driven Design - Eric Evans
- Laravel公式ドキュメント - Database
- 前記事: LaravelでRepository Patternを採用しなかった理由
以上、LaravelでModular Monolithを実装した話でした。
📚 前編記事
本記事は以下の記事の続編です。
まだ読んでいない方は、ぜひ前編もご覧ください。
👉 LaravelでRepository Patternを採用しなかった理由 - 200テーブル規模の実務で辿り着いたシンプルなアーキテクチャ
前編では、
- なぜRepository Patternを採用しなかったか
- Active Recordパターンの活用方法
- UseCase / Service / Modelの責務分離
- テスト戦略
について解説しています。
エンジニア募集
弊社では絶賛エンジニア募集中です!
気になった方、是非お気軽に Wantedly からご連絡ください!