壊れにくいDrupalの作り方 モジュラーモノリス実践ガイド

新田 幸子
壊れにくいDrupalの作り方 モジュラーモノリス実践ガイド

最近、社内ポータルの設計に取り組んでいます。Drupalというモノリスをいかに壊れにくく作るか?試行錯誤の末にたどり着いたのは、Drupalが元々持っているモジュールの仕組みをフル活用する、つまり「モジュラーモノリス」の考え方を意識的に取り入れるという結論でした。

本記事では、モジュラーモノリスの考え方を整理した上で、Drupalのモジュール設計に活かすための実践的な指針を紹介します。

モジュラーモノリスとは

ひとことで言えば、1つのアプリケーションの中に明確なモジュール境界を設ける設計です。マイクロサービスのように分散システムの複雑さを抱えることなく、モジュール間の独立性を高められます。Shopifyがマイクロサービスではなくこのアプローチを選択したことで話題になりました。(参考: Shopifyにおけるモジュラモノリスへの移行(Qiita)

主なメリットは以下のとおりです。

  • ネットワーク遅延がない。 モジュール間の呼び出しはプロセス内で完結するため、マイクロサービスのようなサービス間通信のオーバーヘッドがない。
  • デプロイが単純。 デプロイ対象が1つなので、サービス間のバージョン整合性を気にする必要がない。
  • トランザクション管理が容易。 分散トランザクションを考えなくてよい。
  • テスト・デバッグがしやすい。 単一プロセスで動くため、マイクロサービスに比べて問題の再現や追跡が簡単。
  • モジュール単位で責務が明確になる。 モノリスの手軽さを維持しつつ、コードの見通しがよくなる。

Shopifyはこの構造を持っていなかったRailsに、後からモジュール境界を導入するために大きな労力を費やしました。専用のツール(Packwerk)まで自作しています。

Drupalはすでにモジュラーモノリス

Drupalエンジニアの方ならお気づきかと思いますが、Drupalのモジュール構造はまさにモジュラーモノリスそのものです。コア・contribモジュール・カスタムモジュールという階層構造があり、サービスやイベントを通じてモジュール同士が連携します。Shopifyが後から苦労して手に入れた構造を、Drupalは最初から持ってます。

でも、現場では...

せっかくモジュールシステムという強力な仕組みがあるのだから、それを活かさない手はありません。しかし実際のプロジェクトでは、こんなことが起きがちです。

  • 1つのカスタムモジュールに何でもかんでも詰め込んでしまう。 「custom_utils」のような万能モジュールが生まれ、どんどん肥大化する。
  • モジュール間の依存がグチャグチャになる。 どのモジュールがどのモジュールに依存しているか、誰も把握できない状態に。
  • hookでどこからでも何でも触れてしまう。 便利な反面、意図しない場所から別モジュールのデータを書き換えてしまう。

仕組みがあるだけでは不十分で、意識して境界を守らないとモノリスの沼に落ちるということです。

境界を守るためにできること

実践の中で効果を感じている指針を3つ挙げます。

ドメイン境界でモジュールを分ける

申請フォームと承認ワークフローは一緒に使われる機能ですが、ドメインとしては別物です。「一緒に使うから1つにまとめる」ではなく、「責務が違うなら分ける」と考えます。(DDDでいう「境界づけられたコンテキスト(Bounded Context)」の考え方に近いです。)

❌ Bad: 1つのモジュールに全部入り

custom_workflow/        ← 何でも入りモジュール
├── 申請フォームのロジック
├── 承認ワークフローのロジック
└── 通知のロジック

⭕ Good: ドメインごとにモジュールを分割

application_form/       ← 申請フォームのロジック
      |
      v
approval_workflow/      ← 承認ワークフローのロジック
      |
      v
notification/           ← 通知のロジック

1つのモジュール内でMVCを完結させる

Controller、テンプレート、ロジックがモジュール内に揃っていれば、そのモジュールだけ見ればその機能を理解できます。技術レイヤーではなくドメインでコードをまとめる発想です。(ソフトウェア設計でいう「高凝集(High Cohesion)」の実践です。)

❌ Bad: 技術レイヤーで分割(どこに何があるかわからない)

modules/custom/
├── controllers/
│   ├── ApplicationFormController.php
│   └── ApprovalWorkflowController.php
├── services/
│   ├── ApplicationFormService.php
│   └── ApprovalWorkflowService.php
└── templates/
    ├── application-form.html.twig
    └── approval-workflow.html.twig

⭕ Good: ドメインで分割(モジュール内にMVCが揃う)

modules/custom/
├── application_form/
│   ├── src/Controller/ApplicationFormController.php
│   ├── src/Service/ApplicationFormService.php
│   └── templates/application-form.html.twig
└── approval_workflow/
    ├── src/Controller/ApprovalWorkflowController.php
    ├── src/Service/ApprovalWorkflowService.php
    └── templates/approval-workflow.html.twig

依存を一方向にする

たとえば「申請フォーム」モジュールと「承認ワークフロー」モジュールがあるとき、申請フォームから承認ワークフローを参照するのはOKだけど、逆方向の依存は作らない。方向を決めるだけで構造が劇的に整理されます。(これはRobert C. Martinが『Clean Architecture』で提唱する「非循環依存の原則(ADP)」にあたります。)

❌ Bad: 双方向に依存している

申請フォーム <--依存--> 承認ワークフロー

⭕ Good: 一方向の依存

申請フォーム --依存--> 承認ワークフロー
# application_form.info.yml
dependencies:
  - approval_workflow  # 申請フォーム → 承認ワークフローへの依存のみ

# approval_workflow.info.yml
dependencies: []       # 逆方向の依存はなし

境界を超えて連携するには

モジュールを分けたら、次に考えるのは「どうやって連携するか」です。境界を守りつつモジュール間で連携する方法は、大きく2つあります。

相手を知っている場合 → サービスコンテナ経由で呼ぶ

モジュールが公開するServiceをDI(依存性注入)で利用します。依存が明示的になり、.info.ymlのdependenciesとも一致します。

// application_form モジュールから approval_workflow のサービスを利用する
class ApplicationFormService {
  public function __construct(
    private ApprovalWorkflowService $approvalWorkflow,
  ) {}

  public function submit(array $data): void {
    // 申請データを保存した後、承認ワークフローを開始
    $this->approvalWorkflow->start($data);
  }
}

相手を知らなくていい場合 → イベントで疎結合にする

「承認が完了した」というイベントをdispatchし、通知モジュールがsubscribeする形です。呼び出す側が相手を知らないので、依存の方向が片方向に保てます。

// approval_workflow モジュール:イベントを発火するだけ
$event = new ApprovalCompletedEvent($entity);
$this->eventDispatcher->dispatch($event);

// notification モジュール:イベントを受け取って通知する
class ApprovalNotificationSubscriber implements EventSubscriberInterface {
  public static function getSubscribedEvents() {
    return [ApprovalCompletedEvent::class => 'onApprovalCompleted'];
  }

  public function onApprovalCompleted(ApprovalCompletedEvent $event): void {
    // 承認完了の通知を送る
  }
}

拡張ポイントを提供する場合 → プラグインで差し替え可能にする

Drupalのプラグインシステムも、親が子を知らなくていい仕組みとして強力です。プラグインマネージャーがインターフェースに基づいて実装を自動検出するため、呼び出す側は具体的な実装を知る必要がありません。社内ポータルでもカスタムプラグインを随所で活用しています。

// プラグインマネージャー経由で実装を取得(具体的なクラスを知らなくていい)
$plugins = $this->approvalStepManager->getDefinitions();
foreach ($plugins as $plugin_id => $definition) {
  $instance = $this->approvalStepManager->createInstance($plugin_id);
  $instance->execute($entity);
}

hookの使いすぎに注意

hookはDrupalの伝統的な連携手段ですが、どこからでも何でも触れてしまうため、モジュール間の境界を簡単に壊します。

ありがちなのが「Aを更新したらBも更新する」というケースです。

❌ Bad: AのpostSaveフックでBを直接更新する

// module_a.module
function module_a_entity_postsave(EntityInterface $entity) {
  // AのhookからBを直接更新 → AがBの内部構造を知ってしまう
  $b = Node::load($entity->get('field_related')->target_id);
  $b->set('field_status', 'updated');
  $b->save();
}

この書き方だと、AがBの内部構造(フィールド名やロジック)を知っている必要があり、BのフィールドやロジックAを変更したときにAのhookも壊れます。hookが増えるほど影響範囲が追えなくなります。

⭕ Good①: イベントで疎結合にする

// module_a: イベントを発火するだけ。Bの存在を知らない
$event = new EntityAUpdatedEvent($entity);
$this->eventDispatcher->dispatch($event);

// module_b: イベントを受け取って自分自身を更新する
class ModuleBSubscriber implements EventSubscriberInterface {
  public static function getSubscribedEvents() {
    return [EntityAUpdatedEvent::class => 'onEntityAUpdated'];
  }

  public function onEntityAUpdated(EntityAUpdatedEvent $event): void {
    // Bの更新ロジックはBの中に閉じる
  }
}

⭕ Good②: AとBの更新をまとめるサービスを作る

// 上位のサービスがAとBの更新をオーケストレーションする
class SyncService {
  public function __construct(
    private ModuleAService $moduleA,
    private ModuleBService $moduleB,
  ) {}

  public function updateBoth(array $data): void {
    $this->moduleA->update($data);
    $this->moduleB->syncWith($data);
  }
}

hookに頼りすぎると依存関係が暗黙的になり、影響範囲が追えなくなります。境界を意識した設計では、サービス・イベント・プラグインを優先し、hookは最小限に留めるのがおすすめです。

⭕ Good: 連携手段を使い分ける

申請フォーム --サービス経由--> 承認ワークフロー
                              |--イベント発火--> 通知
                              |--プラグイン----> 承認ステップの実装

基本はサービス経由、モジュールが相手を知る必要がないケースはイベントプラグイン、という使い分けがシンプルです。

まとめ

Drupalはモジュラーモノリスの仕組みを最初から備えています。しかし、仕組みがあるだけでは不十分で、意識して境界を設計しなければモノリスの沼に落ちてしまいます。

本記事で紹介した指針をまとめます。

  • ドメイン境界でモジュールを分ける。 「一緒に使うから」ではなく「責務が違うなら分ける」。
  • 1モジュール内でMVCを完結させる。 技術レイヤーではなくドメインでまとめる。
  • 依存は一方向に保つ。 循環依存を避けるだけで構造が整理される。
  • 連携にはサービス・イベント・プラグインを使い分ける。 hookに頼りすぎない。

社内ポータルの設計を通じて実感したのは、Drupalのモジュールシステムは正しく使えば十分に強力だということです。特別なツールを導入しなくても、設計の意識を変えるだけでコードの見通しは大きく改善します。

新田 幸子/ Drupalエンジニア

北海道在住のDrupalエンジニアです。バックエンドを主に担当しています。三度の飯よりAIが好きです。趣味はドライブ、猫と遊ぶことです。

新田 幸子 の書いた記事一覧

最新の関連記事

Download 資料ダウンロード

Drupalでの開発・運用、サーバー構築、Webサイト構築全般、制作費用などに関してお気軽にご相談ください。


Contact お問い合わせ

Drupalでの開発・運用、サーバー構築、Webサイト構築全般についてお気軽にご相談ください。専門スタッフによるDrupal無料相談も行なっております。