👏

Fat Controllerを卒業する - オニオンアーキテクチャ実践記

に公開

オニオンアーキテクチャについて

本記事では、LT会で発表した「オニオンアーキテクチャ」について、スライドと口頭説明の内容をまとめて整理します。オニオンアーキテクチャの概要と背景となる考え方、そして実際にMVC構成のアプリケーションをリファクタリングした経験を中心に書いていきます。


そもそもアーキテクチャとは

アーキテクチャとは、システムの骨組みや構造設計のことを指します。
これを正しく選定することで、システムは変更に強くなり、安定性も向上します。
オニオンアーキテクチャは、ソフトウェアに関するアーキテクチャの一つです。


オニオンアーキテクチャの位置づけ

オニオンアーキテクチャは、ドメイン駆動設計(DDD)に基づいたソフトウェアアーキテクチャです。

私の理解では、DDDは「ある業務をシステムに落とし込むときの設計手法」、
一方でソフトウェアアーキテクチャは「実際にシステムを作るときに、どう構成するか」をまとめたものです。

DDDの書籍の中では、以下のようなアーキテクチャが代表例として紹介されています。

  • レイヤードアーキテクチャ
  • ヘキサゴナルアーキテクチャ
  • オニオンアーキテクチャ
  • クリーンアーキテクチャ

今回はその中でも、オニオンアーキテクチャを取り上げました。


オニオンアーキテクチャとは何か

オニオンアーキテクチャは、文字通り玉ねぎのような同心円構造をしています。
中央にドメインモデルがあり、その外側に以下のような層が積み重なっています。

  • Domain Model
  • Domain Service
  • Application Service
  • User Interface / Infrastructure

特徴的なのは、「すべての依存関係が円の中心、つまり内側に向かう」という点です。

図をもう少し噛み砕くと、以下のようなレイヤ構成になります。

  • プレゼンテーション層
  • アプリケーション層
  • ドメイン層
  • インフラ層

依存関係は内向き、層の境界はインターフェースで切られます。


前提知識:レイヤードアーキテクチャの課題

オニオンアーキテクチャを理解する前提として、レイヤードアーキテクチャがあります。

レイヤードアーキテクチャでは、一般的に以下の構成になります。

  • プレゼンテーション層(Controller)
  • アプリケーション層(UseCase / Service)
  • ドメイン層(Entity, ValueObject, DomainService)
  • インフラ層(Repository, DataAccess)

DDDでは、ドメイン層は他の層から独立していることが望ましいとされています。
この構成で問題になるのが、ドメイン層がインフラ層に依存してしまうことです。

その結果、以下のような問題が起こります。

  • モックデータを用意しづらく、ユニットテストが書きにくい
  • DBを変更する際に、ドメイン層まで修正が必要になる

依存性逆転の原則

ここで重要になるのが、SOLID原則の一つである「依存性逆転の原則」です。

  • 上位モジュールは下位モジュールに依存してはならない
  • 両者は抽象(インターフェース)に依存すべき
  • 抽象は詳細に依存してはならない
  • 詳細が抽象に依存すべき

この「抽象」というのが、具体的にはインターフェースを指します。


オニオンアーキテクチャにおける依存関係

オニオンアーキテクチャでは、この依存性逆転の原則を適用します。

  • ドメイン層にリポジトリのインターフェースを定義する
  • インフラ層はそのインターフェースを実装する

これにより、インフラ層がドメイン層に依存する形になり、依存関係が逆転します。

その結果、ユニットテスト時にはモックのRepositoryを差し替えるといった実装が可能になります。

コード例

具体的にコードで表すと以下のようになります。
悪い例
以下のコードでは、OrderServiceが具体的なMySqlDatabaseに依存しているため、データベースを変更する場合にOrderServiceの修正が必要になります。

// 具体的な実装に直接依存している
public class OrderService
{
    private MySqlDatabase _database;
    public OrderService()
    {
        _database = new MySqlDatabase(); // 具体的なクラスに依存
    }
    public void SaveOrder(Order order)
    {
        _database.Insert(order);
    }
}
 
public class MySqlDatabase
{
    public void Insert(Order order)
    {
        // MySQL固有の実装
    }
}

良い例:依存関係を逆転させた実装
以下のコードでは、OrderServiceが抽象化したIOrderRepositoryに依存しているため、データベースを変更する場合はIOrderRepositoryを継承した新しいクラスを実装し、差し替えるだけで切り替えることが可能になります。

// インターフェースの定義
public interface IOrderRepository
{
    void Save(Order order);
    Order GetById(int id);
}
 
// 高レベルモジュール(ビジネスロジック)
public class OrderService
{
    private readonly IOrderRepository _repository;
    // インターフェースに依存(依存性注入)
    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }
    public void ProcessOrder(Order order)
    {
        // ビジネスロジック
        order.Calculate();
        _repository.Save(order);
    }
}
 
// 低レベルモジュール(実装の詳細)
public class MySqlOrderRepository : IOrderRepository
{
    public void Save(Order order)
    {
        // MySQL固有の実装
    }
    public Order GetById(int id)
    {
        // MySQL固有の実装
        return null;
    }
}
 
// 別の実装も簡単に追加可能
public class MongoDbOrderRepository : IOrderRepository
{
    public void Save(Order order)
    {
        // MongoDB固有の実装
    }
    public Order GetById(int id)
    {
        // MongoDB固有の実装
        return null;
    }
}
 
// 使用例
public class Program
{
    public static void Main()
    {
        // 実装の切り替えが容易
        IOrderRepository repository = new MySqlOrderRepository();
        // または
        // IOrderRepository repository = new MongoDbOrderRepository();
        var orderService = new OrderService(repository);
        orderService.ProcessOrder(new Order());
    }
}

実践:MVCからオニオンアーキテクチャへのリファクタリング

ここからは、実際に私が行ったリファクタリング事例です。
MVC構成のLaravelアプリケーションを、オニオンアーキテクチャへとリファクタリングしました。

対象システムの概要

施設の予約管理システムを例にあげます。
要件はほかにもありますが、簡単にまとめると以下の通りです。

  • 利用者が予約申請し、管理者が承認する
  • 利用枠は「午前中・午後・夜間」
  • 管理者承認時の料金調整
    • 公共団体利用:減免
    • 営利目的利用:金額加算

必要な処理

予約申請時

  • 整合性チェック(空いている時間に申請されているか)
  • 料金計算(減免・加算なし)
  • 予約情報登録(ステータス:申請中)

予約承認時

  • 整合性チェック
  • 料金計算(減免・加算を考慮)
  • 予約情報更新(ステータス:予約)

リファクタリング前の構成

変更前は、Laravelのデフォルトに近いMVC構成でした。

以下のような問題があり、可読性や保守性が低下している状態でした。

  • Controllerに処理が集中
  • いわゆるFat Controller状態
  • 予約に関する処理をすべてControllerが担当

リファクタリング後の構成

リファクタリング後は、以下のようにフォルダと責務を分離しました。

ドメイン層

  • DomainService
    • ReserveDomainService
  • Entities
    • Room
    • Reserve
  • ValueObjects
    • Exemption
    • Magnification
    • ReserveStatus

プレゼンテーション層

  • Controllers
  • Requests

インフラ層

  • Models
  • Repositories
  • ドメイン層で定義したRepositoryインターフェースの実装

アプリケーション層

  • UseCases
    • ApplyUseCase
    • ApproveUseCase

インターフェースと実装クラスは管理しやすさを優先し、近い場所に配置しました。


UseCaseでの処理の流れ

予約申請(ApplyUseCase)

  • 必要なIRepositoryを受け取る
  • ReserveDomainServiceで整合性チェック
  • Entity(Room)で料金計算
  • Entity(Reserve)で予約情報登録

予約承認(ApproveUseCase)

  • 必要なIRepositoryを受け取る
  • ReserveDomainServiceで整合性チェック
  • Entity(Room)で料金計算(減免・加算考慮)
  • Entity(Reserve)で予約情報更新

Controllerの役割は、UseCaseを生成して実行し、結果を返すのみとしました。
Repositoryは、DBアクセス処理(データ取得・更新)だけを担当します。


実践してみたメリット・デメリット

メリット

  • 1ファイルあたりのコード量が減り、読みやすくなった
  • 依存関係を整理しやすくなった
  • 処理が分散し、コンフリクトが起きにくくなった
  • モック用のRepositoryを作ればユニットテストが書ける

デメリット

  • ファイル数が圧倒的に増えた
    • 規模が大きいプロダクトでは、ファイル数がさらに膨らみそう
  • 新メンバー参加時の学習コストが高い

おわりに

DDDやアーキテクチャについては、まだ理解が曖昧な点も多くあります。
また、これを実際のプロダクトにどう活かしていくかも、まだ模索中です。

今後も勉強と試行錯誤を続けながら、よりよい設計とコードを目指していきたいと思います。

Thinkingsテックブログ

Discussion