😊

NestJSと戦術的DDDのいいとこどりをしてバックエンドTypescriptの設計をした話

2025/02/05に公開

はじめに

バックエンド開発にもTypeScriptを利用する事例、増えてますね。NestJSはTypescriptバックエンドの中でも、唯一のopinionatedなフレームワークとして採用しました(特に大規模な開発を目指している弊社では相性がいい)。しかし、実際にNestJSを使って開発を進めると「too much」と言われる前評判とは異なり、「not enough」という印象を受けました。特に、DDDの観点からすると、DTO(Data Transfer Object)やControllerなどプレゼンテーション層は整備されているものの、Service層にユースケース層、ドメイン層、インフラ層の役割を一括して詰め込んでしまい、結果として「Fat Service」になりがちです。

image

image

そこで、本記事ではNestJSの機能を活かしつつ、戦術的DDDのパターンを導入して、Service以下のコードをドメイン層、ユースケース層、インフラ層に明確に分離する設計手法について紹介します。

NestJSの特徴と利点

NestJSは、Node.js環境でモダンなバックエンドアプリケーションを構築するために設計されたフレームワークです。主な特徴と利点は以下の通りです。

  • TypeScriptファースト
    TypeScriptによる型安全なコーディングが可能で、開発中のエラーを早期に検知できるため、プロダクトの信頼性が向上します。

  • モジュールシステム
    アプリケーションを機能ごとに分割するモジュール設計が採用されており、責務が明確になります。これにより、各機能の開発やテストが効率的に行えます。

  • 依存性注入(DI)
    DIコンテナにより、コンポーネント間の依存関係が明示的に管理され、テスト容易性や保守性が向上します。

  • 豊富な拡張機能
    DTOやControllerといったプレゼンテーション層の実装が容易なほか、GraphQL、WebSocket、Microservicesといった多様な拡張機能が用意され、幅広いユースケースに対応できます。

戦術的DDDの主要パターン

戦術的DDDは、複雑なビジネスロジックを効果的に表現するための具体的なパターンを提供します。ここでは、主要なパターンを簡単に振り返ります。

  • エンティティ
    各オブジェクトは識別子(ID)を持ち、ライフサイクル全体で同一性を維持します。たとえば「ユーザ」や「注文」など、ビジネス上の主要なオブジェクトが該当します。

  • 値オブジェクト
    識別子を持たず、属性の集合として機能するオブジェクトです。たとえば「メールアドレス」や「住所」など、変更されるべきでない情報を表現します。

  • アグリゲート
    関連するエンティティや値オブジェクトの集合で、ビジネスルールに基づく一貫性の境界を形成します。アグリゲートルートは外部とのやりとりの唯一の窓口となります。

  • リポジトリ
    永続化層との橋渡し役を担い、ドメインオブジェクトの取得や保存を抽象化します。これにより、インフラストラクチャの変更がドメインロジックに影響を与えにくくなります。

  • ドメインサービス/ドメインイベント
    エンティティや値オブジェクトに属さない複雑なビジネスロジックを実装したり、システム内の重要な事象を伝播するための仕組みです。

NestJSと戦術的DDDの融合

弊社ではPureなNestJSではなく、nestjs-trpcを利用しています。

https://zenn.dev/ficilcom/articles/nestjs-trpc-compare

Router→Controller、Schema→DTOに相当するので、適宜読み替えてください。

NestJSは、RouterファイルやSchemaファイルなどのプレゼンテーション層の実装には非常に適しています。しかしServiceファイルは、ユースケース層、ドメイン層、インフラ層などさまざまな責務を一手に引き受けがちで、結果として膨大で肥大化した実装になってしまう傾向があります。

そこで、実際の開発現場で感じたのは、NestJS単体ではプレゼンテーション層以外の部分が「not enough」であり、適切なレイヤー分離ができないという問題点でした。

この問題を解決するため、NestJSのService層以下の処理を、以下の3つの層に明確に分離します。

  • ドメイン層
    ビジネスロジックをエンティティや値オブジェクトに閉じ込め、純粋なビジネスルールとして実装します。

  • ユースケース層
    ユースケースに沿った処理を実装し、ドメイン層のオブジェクトを組み合わせてビジネスプロセスを実現します。

  • インフラ層
    永続化や外部サービスとの連携など、技術的な実装を担います。ここでは、PrismaなどのORMが利用され、実際のデータアクセスを管理します。

このようにDDDにならって各層を明確に分離することで、コードの可読性や保守性、拡張性が大幅に向上し、テストもしやすい設計を実現します。

設計例

turborepoを利用して以下のようなディレクトリ構成にしています。

t3-turbo
|   - apps
|     | - api       //NestJS
|   - packages
|     | - core      //DDD
|   - package.json
|   - pnpm-lock.yaml
|   - turbo.json

これによりapi配下はNestJSの文脈で、core配下がDDDの文脈で実装を進めることができます。

packages/core

src
|   - domain
|     | - entity
|     | - repository
|   - usecase
|     | - [domainA]
|   - error
|   - index.ts
package.json

core配下にはドメイン層とユースケース層を定義します。ここでの注意点は、あくまでドメインとユースケースを表現するため、外部やライブラリに依存せず、PureなTypescriptで書きます。

apps/api

src
|   - [DomainA]
|     | - [DomainA].module.ts
|     | - [DomainA].router.ts
|     | - [DomainA].schema.ts
|     | - [DomainA].service.ts
|   - infrastructure
test
package.json

インフラ層はPrismaなどのORMに依存するため、NestJS側(apps/api内)に配置します。これにより、データベースとの連携が容易になります。
Servieファイルの中で、データアクセスに関する処理呼び出し、ユースケース層に注入します。

このような構成により、各層の責務が明確になり、後々の拡張や変更が局所的に完結する設計が実現できました。

また依存関係を考慮してcore → apiの順でbuildできるので、turborepoを採用したメリットが生かされます。

このアーキテクチャのメリットと課題

メリット

  • ビジネスロジックの明確化と閉じ込め
    各層で責務を分離することで、ビジネスルールがエンティティやユースケースに明確に表現され、システム全体の理解が容易になります。

  • レイヤー分離による保守性・拡張性の向上
    プレゼンテーション、ユースケース、ドメイン、インフラの各層が独立しているため、変更が局所化され、全体への影響を最小限に抑えることができます。

  • DIを活用したテスト容易性
    各コンポーネントを依存性注入(DI)によりモック化できるため、ユニットテストや統合テストが容易に行えます。

課題

とにかく「初期の設計・学習コストの高さ」
特にDDDは各人の理解度や解釈によってばらつぎが出ます。優秀なメンバーのおかげで無事設計ルールを決め切ることができました。

おわりに

NestJSと戦術的DDDの組み合わせによる設計は、複雑なビジネスロジックを持つバックエンドシステムにおいて非常に有効なアプローチです。今回紹介した設計手法により、各層の役割が明確になり、コードの可読性や保守性、テスト容易性が大幅に向上しました。もちろん、初期の設計や学習コストが高いという課題も存在しますが、長期的な視点で見ると、柔軟で拡張性の高いアーキテクチャの構築につながります。

このアプローチを参考に取り入れていただければ幸いです。

フィシルコム

Discussion