バックエンドTypeScriptでオニオンアーキテクチャを運用してわかった手応えと反省点

に公開

弊社ではT3-Turboを導入しており、フロントエンドにNext.js、サーバーサイドにNestJSを採用しています。
本記事では特にサーバーサイドのアーキテクチャに焦点を当て、私たちが実践しているドメイン駆動設計(DDD)について、その運用から見えてきたメリット、デメリット、そして今後の課題を考察・共有します。

1. アーキテクチャの全体像

弊社ではDDDの実現方法としてオニオンアーキテクチャを採用しています。

クリーンアーキテクチャではなくオニオンアーキテクチャを選択した理由は、後者の方が usecase(アプリケーション層)と domain(ドメイン層)の境界がより明確 であり、「ビジネスの関心事をドメインとして独立させる」という私たちの設計思想と強く合致したためです。特に、オニオンアーキテクチャがドメイン層からアプリケーション層への依存を許さないという原則は、私たちが目指すドメインの純粋性を保つ上で非常に重要でした。

まず、弊社におけるオニオンアーキテクチャの具体的なディレクトリ構造を以下に示します。

.
├── utils
├── usecase
└── domain
    ├── entity
    │   └── value-object
    ├── repository
    └── service
        ├── specification
        └── policy

1-1. domainレイヤー

アプリケーションの心臓部であり、ビジネスの概念とルールを表現する最も内側のレイヤーです。このレイヤーは、特定のフレームワークやデータベース技術から完全に独立している必要があります。

1-1-1. entity / value-object

  • entity:
    ビジネスにおける「モノ」や「コト」を表す、一意なIDを持つオブジェクトです。単なるデータの入れ物ではなく、そのエンティティに関するビジネスルールや振る舞い(メソッド)を持ちます。これを「リッチドメインモデル」と呼びます。例えば、Job(求人)エンティティは、求人情報を保持するだけでなく、「公開可能か検証する」といったロジックを自身で持ちます。

  • value-object(値オブジェクト):
    IDを持たず、値そのものに意味があるオブジェクトです。例えば、「給与」や「勤務地」などが該当します。値の正当性を検証するロジックを持ち、不変(Immutable)であることが特徴です。

1-1-2. repository

エンティティの永続化(保存、取得、削除など)を抽象化するためのインターフェース(契約)を定義します。あくまで「どのような操作ができるか」を定義するだけで、具体的なデータベースへのアクセス方法はここでは記述しません。このインターフェースは、外側のレイヤー(インフラストラクチャ層)で実装されます。

1-1-3. service

単一のエンティティに持たせるには不自然な、複数のエンティティをまたぐドメインロジックを配置します。例えば、「ある企業の求人を作成する際に、その企業の契約プランの上限を超えていないかチェックする」といった処理が該当します。

ただ service という言葉は実装者によって解釈が曖昧になる可能性があるので、弊社では以下2つに絞って運用しています。

  • specification(仕様):
    「公開可能な求人の条件」のように、特定のオブジェクトが満たすべき条件(仕様)をカプセル化し、再利用可能な形で表現するためのパターンです。複雑なビジネスルールを明確な部品として切り出すことができます。

  • policy(方針):
    より広範なビジネス上の方針や制約を表現します。Specificationが「Yes/No」を判定するルールであるのに対し、Policyはより複雑な判断や、状況に応じたアクションの決定などを含むことがあります。

後述のとおりデメリット解消のために導入したレイヤーのため、まだ検証段階となっています。

1-2. usecaseレイヤー

domainレイヤーの調整役として、アプリケーション固有のユースケース(ユーザーの操作シナリオ)を実現する層です。このレイヤー自体は、ビジネスルールを一切持ちません。

usecaseの責務は、domainレイヤーのオブジェクト(エンティティ、リポジトリ、ドメインサービスなど)を適切に呼び出し、一連の処理フローを管理することです。例えば、「企業担当者が求人を作成する」というユースケースは、以下のような流れを記述します。

  1. 入力データを受け取る。
  2. ドメイン層(エンティティのコンストラクタやファクトリ)に、そのデータを使ってJobエンティティを生成するよう依頼する。
  3. JobRepositoryに、生成されたJobエンティティを永続化(保存)するよう指示する。

このように、usecaseはドメインオブジェクトがどのように作られ、どのようなルールを持っているかというビジネスロジックの詳細を知りません。

usecaseが管理するのは、あくまアプリケーションとしてのワークフローです。例えば、「トランザクションを開始し、ドメイン層のロジックを呼び出し、結果をリポジトリで永続化し、最後にトランザクションを終了する」といった、より大局的で技術的な流れの調整に専念します。

一方で、「求人を公開する前に、企業の契約プランの上限を必ずチェックする」といったビジネスルール上の手順は、ドメインサービスが責務を持ちます。usecaseはそのドメインサービスを呼び出すだけで、その中にどのような手順が隠蔽されているかを知ることはありません。トランザクションの管もこのレイヤーの重要な責務です。

1-3. utilsレイヤー

最も外側に位置する、あるいは特定のレイヤーに属さない横断的なレイヤーです。日付操作や文字列フォーマットなど、ドメイン知識を全く含まない、アプリケーション全体で再利用可能な汎用的なヘルパー関数などを配置します。

2. 運用する中で感じたメリット・価値

以下、上記アーキテクチャで運用して感じた価値です。

2-1. 高いテスト容易性

ビジネスロジックがフレームワークやデータベースから完全に独立しているため、ドメイン層の単体テストが非常に容易です。クリティカルなロジックの品質を、外部要因に左右されずに担保できる点は大きなメリットでした。

2-2. AIフレンドリーな開発体験

各層の責務が明確に分離されているため、AIコーディングアシスタントとの相性が非常に良いと感じています。「このUsecaseを実装して」「このRepositoryのテストを書いて」といった指示が明確になり、AIの能力を最大限に引き出しながら、人間はより本質的な設計の判断に集中できました。

メリットとして変更に強いことが挙げられますが、まだ大規模開発のレベルに達していないためそのメリットは享受できていないように感じます。

3. 運用する中で感じた課題

次に、私たちが直面した課題と、それに対する考察です。

1. ドメインサービスを用意しなかった

オニオンアーキテクチャでは、複数のエンティティをまたぐドメインロジックは「ドメインサービス」に記述するのが定石です。しかし私たちは「サービス」という言葉の曖昧さを懸念し、明確なドメインサービスを設けませんでした。

その結果、本来ドメイン層にあるべきロジックがUsecase層に漏れ出し、クリーンアーキテクチャに近い思想が混在する中途半端な状態になりました。さらに、エンティティが他のエンティティ(のリポジトリ)に依存するような箇所も生まれ、ドメイン層の独立性が損なわれる課題に繋がりました。

アーキテクチャの思想を貫くなら早期にドメインサービスを定義すべきでした。現在はこの課題に対しSpecificationPolicyといった、より責務の明確なパターンを導入することで解決できないか検証を進めています。

2. 変更箇所の増大

簡単な修正でも、EntityからRouterまで多くのファイルを横断的に変更する必要があり、作業コストが高くなりがちです。これはコードレビューの負荷増大にも直結しており、レビューを受ける側の修正工数も増える形になり、メンバーの感情要素(やる気など)にも影響してきます。

3. パフォーマンス問題

メンバーの多くがDDD初挑戦だったため、まずは基本に忠実にRepositoryを実装・利用しました。結果として、複数のRepositoryを組み合わせた処理でパフォーマンス問題(N+1など)が発生し、参照系処理の最適化(CQRSの導入など)が今後の課題となっています。

4. チーム全体の学習コストと共通理解の壁

このアーキテクチャを正しく運用するには、メンバー全員が高いレベルの知識を求められます。

DDDの根本的理解(戦略的/戦術的の関連)に加え、クリーンアーキテクチャとオニオンアーキテクチャの思想的な違いまで理解する必要がありますが、mtgや勉強会のような共通の学習機会を提供できず理解度にばらつきが生まれがちでした。
チャットやコードレビューを通じて伝えてはいますが断片的な知識にしかなっていないように感じており、体系的な理解を促進できるような枠組みが必要だと感じています。

またこの課題はエンジニアだけの話ではありません。なぜDDDを導入したいのか、DDDで運用することでどのような結果になっていてほしいのか。上位目的に対する共通認識をPMやビジネスオーナーも含めたチーム全体で共有できていないとDDDの本来の価値は発揮できないと痛感しました。

まとめ

以上、オニオンアーキテクチャを運用しながら感じた価値・課題について考察しました。

今回改めて言語化してみて、コードをどう書くかという戦術的な部分以上に、なぜこの設計を選ぶのかという戦略的な目的をチーム全体で共通理解することが重要だと感じました。

これからも学習と経験を重ね、今回共有したような課題と向き合いながら、このアーキテクチャをプロダクトと共に育てていきたいと考えています。本記事が、同じようにDDDに取り組む方々の参考になれば幸いです。

フィシルコム

Discussion