ロジックは内、副作用は外 Functional Core / Imperative Shell入門

新田 幸子

はじめに ── テストの「目的」が見えていますか?

AIを使ってコードを書く機会が増えました。手で書く量は確かに減っています。それでも、コードや設計を読んで判断する目は依然として必要です。AIが生成したコードは一見それらしく見えますが、良い設計かどうかを見極めるのは人間の仕事です。

ソフトウェア開発にはコードや設計を改善するためのコンセプトや手法がたくさんあります。今回はその中から、Functional Core / Imperative Shell(FCIS) を取り上げます。テストという切り口から、設計の考え方を整理してみたいと思います。

ユニットテストを書いていて、こんな状況になったことはないでしょうか。

// このテスト、何を確認しているんだっけ?
$repoMock   = $this->createMock(TaskRepository::class);
$mailerMock = $this->createMock(MailerInterface::class);
$loggerMock = $this->createMock(LoggerInterface::class);
$timeMock   = $this->createMock(ClockInterface::class);
$repoMock->method('findOverdue')->willReturn([...]);
$timeMock->method('now')->willReturn(new DateTimeImmutable('2026-05-25'));
// セットアップがまだ続く...

これはユニットテストのはずです。なのに、モックだらけでセットアップが重く、少し実装が変わると壊れやすい。そして何より、このテストが何を確認しているのかが見えにくい

問題はモックの数ではなく、テストの目的がぼやけていることです。ユニットテストでロジックを見ているのか、統合テストで繋ぎ目を見ているのか、その区別が設計レベルで曖昧になっています。

Functional Core / Imperative Shell(以下 FCIS)は、その問いへの一つの答えです。

FCISとは

FCISは、Gary Bernhardtが2012年に提唱した設計アプローチです。考え方はシンプルで、コードを二つの層に分けます。

別名 役割 特徴
Functional Core 純粋な核 ロジック・計算・判断 副作用ゼロ・純粋関数
Imperative Shell 命令の外殻 DBアクセス・API呼び出し・ファイルI/O・時刻取得 副作用あり・薄く保つ

「副作用」とは何でしょうか。ここでは関数の外の世界に影響を与える、または外の世界に依存する操作と定義します。データベースの読み書き、外部APIの呼び出し、現在時刻の取得、乱数の生成、ファイルI/O、メール送信... これらはすべて副作用です。

副作用をコードの外縁(Shell)に押し出し、内側(Core)を純粋な計算だけにする。

なぜユニットテストが減るのか

Coreは純粋関数の集まりなので、テストがとても書きやすくなります。

// Core関数のテストはこれだけでいい
$result = calculateShippingFee($orderItems, $region, $memberRank);
assert($result === 800);

DBも時計も外部サービスも関係ありません。入力と出力だけを検証すればいいので、モックは不要です。Shellは副作用を担いますが、それ自体は「データを取ってきてCoreに渡し、結果を保存する」だけの薄い接着剤になります。

  • Core → ユニットテストが超軽量(モック不要、assert数行)
  • Shell → カーネルテスト・統合テストに委譲(役割が明確になる)
  • UI・フロー → Playwright(ブラウザ操作はここに集約)

「書くべきテストがシンプルになる」 のがFCISの効果です。

カーネルテストについて カーネルテスト(KernelTestBase)が不要になるわけではありません。「メールが送られたか」「ログが記録されたか」「エンティティが正しく保存されたか」といった副作用の結果確認はPlaywrightには荷が重く、カーネルテストの出番です。FCISで得られるのは「カーネルテストで何をテストすべきかがはっきりする」という整理の効果です。Coreのロジックはユニットテストで済むので、カーネルテストはShellとDrupalの繋ぎ目だけに絞って書けるようになります。

Before / After で体感する

シナリオ:承認期限切れの通知処理

承認待ちのタスクが期限切れになったユーザーへ、リマインドメールを送る処理を考えます。

❌ Before(副作用が混在)

function sendOverdueReminders(): void
{
    // DBアクセス
    $tasks = $this->taskRepository->findOverdue(new DateTime('now'));

    foreach ($tasks as $task) {
        // 判断ロジック(Core相当)がShellの中に埋まっている
        if ($task->getDaysSinceDeadline() >= 3) {
            $subject = '【緊急】承認タスクが期限切れです';
        } else {
            $subject = '承認タスクの期限が過ぎています';
        }

        // メール送信(副作用)
        $this->mailer->send($task->getAssignee()->getEmail(), $subject);

        // ログ(副作用)
        $this->logger->info("Reminder sent: task {$task->getId()}");
    }
}

このコードをテストしようとすると、taskRepositorymailerloggerDateTimenow)のすべてをモック化しなければなりません。そして「件名が緊急になる条件」というたった1行の判断ロジックのために、重いセットアップを書くことになります。

✅ After(CoreとShellに分離)

Functional Core(純粋な判断ロジック)

// 副作用ゼロ。入力→出力だけ
function buildReminderPlans(array $overdueTasks, DateTimeImmutable $now): array
{
    $plans = [];
    foreach ($overdueTasks as $task) {
        $daysSince = $now->diff($task->getDeadline())->days;

        $plans[] = [
            'to'      => $task->getAssignee()->getEmail(),
            'subject' => $daysSince >= 3
                ? '【緊急】承認タスクが期限切れです'
                : '承認タスクの期限が過ぎています',
            'task_id' => $task->getId(),
        ];
    }
    return $plans;
}

Imperative Shell(副作用を一手に担う)

function sendOverdueReminders(): void
{
    // I/Oはすべてここで完結させる
    $tasks = $this->taskRepository->findOverdue();
    $now   = new DateTimeImmutable('now');

    // Coreに渡すのは「ただのデータ」
    $plans = buildReminderPlans($tasks, $now);

    // 結果を使って副作用を実行
    foreach ($plans as $plan) {
        $this->mailer->send($plan['to'], $plan['subject']);
        $this->logger->info("Reminder sent: task {$plan['task_id']}");
    }
}

テストの変化

❌ Before(FCISなし)

ロジックと副作用が混在しているため、ユニットテストはモックだらけになり、カーネルテストにもロジックの検証が混入します。

ユニットテスト:モック地獄

// セットアップだけでこれだけかかる
$repoMock   = $this->createMock(TaskRepository::class);
$mailerMock = $this->createMock(Mailer::class);
$loggerMock = $this->createMock(Logger::class);
$repoMock->method('findOverdue')->willReturn([/* テスト用タスク */]);
// ...まだ続く

カーネルテスト:ロジックの検証も混入

class SendOverdueRemindersTest extends KernelTestBase {

  public function testSendsUrgentSubjectWhenThreeDaysOverdue(): void {
    $task = Task::create([
      'deadline' => '2026-05-20',
      'assignee' => $this->createUser(),
      'status'   => 'pending',
    ]);
    $task->save();

    $service = \Drupal::service('mymodule.reminder_service');
    $service->sendOverdueReminders();

    // 「件名が緊急かどうか」というロジックをカーネルテストで確認している
    $mails = $this->getMails();
    $this->assertStringContainsString('【緊急】', $mails[0]['subject']);
  }
}
問題 「件名が緊急になるかどうか」はビジネスロジックの話なのに、Drupalを丸ごと起動するカーネルテストで検証しています。実行が遅く、セットアップも煩雑です。

✅ After(FCIS適用)

Coreのロジックはユニットテストで、ShellとDrupalの繋ぎ目はカーネルテストで、それぞれ責任が分かれます。

ユニットテスト:モックゼロ、意図が一目でわかる

$tasks = [
    new Task(deadline: new DateTimeImmutable('2026-05-20'), assignee: 'user@example.com'),
];
$now = new DateTimeImmutable('2026-05-25'); // 5日後 → 緊急扱い

$plans = buildReminderPlans($tasks, $now);

assert($plans[0]['subject'] === '【緊急】承認タスクが期限切れです');

カーネルテスト:副作用の結果だけを検証

class SendOverdueRemindersTest extends KernelTestBase {

  public function testShellFetchesOverdueTasksAndSendsMail(): void {
    $task = Task::create([
      'deadline' => '2026-05-20',
      'assignee' => $this->createUser(['mail' => 'user@example.com']),
      'status'   => 'pending',
    ]);
    $task->save();

    $service = \Drupal::service('mymodule.reminder_service');
    $service->sendOverdueReminders();

    // 検証するのは「メールが送られたか」という副作用の結果のみ
    // 件名の中身はユニットテスト側で検証済み
    $mails = $this->getMails();
    $this->assertCount(1, $mails);
    $this->assertSame('user@example.com', $mails[0]['to']);
  }
}
効果 件名の判定ロジックはユニットテスト側に委ねているので、カーネルテストは「ShellがDBからタスクを取得してメールを送ったか」だけを見ます。テストの責任が一本に絞られ、失敗したときの原因も特定しやすくなります。

「これってCoreなのかShellなのか」の見分け方

迷ったときは、この一問を自分に問いかけてみてください。

「この処理、外の世界を触っているか?」

外の世界 = DB、API、ファイル、時計、メール、乱数、セッション... これらを触るならShell。触らないならCore候補です。

特にやりがちなのが現在時刻と乱数を関数の中で取得してしまうことです。new DateTime('now') を関数内に書いた瞬間に、その関数はテストで再現できない「現在」に依存します。Shellで取得して引数として渡す設計にするだけで、Coreとして純粋に保てます。

Drupalで実践する場合の補足

DrupalではノードやエンティティAPIをはじめ、\Drupal::service()\Drupal::entityTypeManager() といったグローバルなサービス呼び出しがいたるところに登場します。これらはDrupal本体への依存そのものなので、Coreからは切り出すのが定石です。

実際には「Drupal依存が一切ない純粋なPHPクラス」まで切り出せたとき、それが真のFunctional Coreになります。NodeInterface を引数に取るより、必要なフィールド値だけを stringarray として受け取る設計にするとより純粋になります。DrupalのAPIに触れる部分はすべてShell(サービスクラスやコントローラ)側に押し出すイメージです。

よくある誤解

「全部Coreにしたらいいのでは?」

それはできません。DBにデータを保存したり、メールを送ったりする処理は必ず必要です。Shellは消せません。目的は「Shellを薄く保ち、Coreを厚くする」ことです。

「クリーンアーキテクチャやヘキサゴナルアーキテクチャと何が違う?」

それらは「どの層が何を知ってよいか(依存の方向)」を定義します。FCISは「副作用があるかないか」で分けます。軸が違うので対立しません。クリーンアーキテクチャを使いながらFCISの発想を取り入れることもできます。

「Coreがどんどん大きくなって管理できなくなりそう」

Coreが大きくなることは問題ではなく、むしろ健全な兆候です。純粋関数の集まりは、副作用がないぶん組み合わせやすく、テストも独立して書けます。Coreを小さな関数に分割することも容易です。

まとめ

FCISのエッセンスは一言でいえば、「副作用を端に追いやれ」 です。

  • Functional Core:副作用ゼロの純粋な計算・判断ロジック
  • Imperative Shell:DB・API・時刻などとやり取りする薄い外殻

この分け方を意識するだけで、テストは驚くほど軽くなります。モックは不要になり、アサーションは単純になり、「このテストで何を確認しているのか」が明確になります。

AIが書いたコードをレビューするにしても、Coreの純粋関数群は入出力が明確なので確認しやすいです。Shellの副作用処理はPlaywrightや統合テストに委ねればいいので、テストの責任分担が自然と整理されます。

私自身も社内ポータルでFCISを実践していますが、設計が、テスト量を決めるのだとひしひしと感じております。

この記事が誰かのお役にたてれば幸いです。

新田 幸子/ Drupalエンジニア

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

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

最新の関連記事

Download 資料ダウンロード

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


Contact お問い合わせ

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