😵

[nestjs] DDDにおけるドメイン間の依存関係改善: 認証ドメインとユーザードメインの分離

2025/02/12に公開

NestJS + GraphQL でドメイン駆動設計における依存性の解決

この記事では、NestJSとGraphQLを使用したドメイン駆動設計(DDD)において、ドメイン間の依存関係を適切に管理する方法について解説します。特に、AuthドメインとUserドメイン間の依存関係を例に、インターフェースとアダプターパターンを使用した依存性解決の実装方法を説明します。

はじめに

Domain-Driven Design (DDD)において、ドメイン間の適切な境界設定と依存関係の管理は重要な課題です。特に認証(Auth)ドメインとユーザー(User)ドメインは密接に関連しがちですが、適切に分離することで、より保守性の高いコードを実現できます。

問題の背景

多くのアプリケーションでは、認証機能とユーザー管理機能が密接に結びついています。しかし、DDDの観点からは、これらは異なる関心事を持つ別々のドメインとして扱うべきです。

リファクタリング前のコード

Auth Entity

// src/domains/auth/domain/entities/auth.entity.ts

import { Token } from '../value-objects/token.value-object';
import { User } from '../../../user/domain/entities/user.entity';
import { AggregateRoot } from '@nestjs/cqrs';

export class Auth extends AggregateRoot {
  public static readonly TOKEN_VALIDITY_DAYS = 7;

  private readonly userId: string;
  private readonly token: Token;
  private readonly user?: User;  // User Entityへの直接的な依存

  private constructor(userId: string, token: Token, user?: User) {
    super();
    this.userId = userId;
    this.token = token;
    this.user = user;
  }

  public static create(userId: string, token: Token, user?: User): Auth {
    if (!userId) {
      throw new Error('User ID is required');
    }
    return new Auth(userId, token, user);
  }

  public getToken(): Token {
    return this.token;
  }

  public getUserId(): string {
    return this.userId;
  }

  public getUser(): User | undefined {  // User型への依存を外部に露出
    return this.user;
  }

  public isTokenExpired(): boolean {
    return this.token.isExpired();
  }

  public toResponse() {  // DTOへの変換ロジックも混在
    return {
      userId: this.userId,
      token: this.token.getValue(),
      user: this.user ? {
        id: this.user.getId(),
        email: this.user.getEmail(),
        name: this.user.getName(),
        // ... その他のユーザー情報
      } : undefined,
    };
  }
}

この実装には以下の問題があります:

  1. User Entityへの直接的な依存

    • User型をプロパティとして保持
    • User型を返すメソッドを公開
    • User Entityのメソッドに直接依存
  2. 責務の混在

    • 認証情報の管理とユーザー情報の管理が混在
    • DTOへの変換ロジックも含まれている
  3. 境界の不明確さ

    • AuthドメインとUserドメインの境界が曖昧
    • ドメイン間の依存関係が制御されていない

Auth Repository Interface

// src/domains/auth/domain/repositories/auth.repository.interface.ts
import { User } from '../../../user/domain/entities/user.entity';

export interface IAuthRepository {
  validateCredentials(email: string, password: string): Promise<User>;  // User型への依存
}

問題点の分析

上記のコードには、以下のような問題があります:

  1. 強い結合(Tight Coupling)

    • Auth DomainがUser Domainに強く依存している
    • Auth EntityがUser Entityを直接importし、プロパティとして保持
    • AuthRepositoryがUser Entityの型に依存
  2. 境界の侵害(Boundary Violation)

    • Auth DomainがUser Domainの内部実装の詳細を知っている
    • User Entityのメソッドに直接依存
  3. 責務の混在(Mixed Responsibilities)

    • 認証ロジックとユーザー管理ロジックが密接に結合
    • AuthRepositoryがユーザーの取得と変換の責務も持っている

リファクタリング後のコード

1. ドメインインターフェースの定義

まず、Auth DomainがUser Domainと通信するためのインターフェースを定義します:

// src/domains/auth/domain/interfaces/auth-user.interface.ts

export interface IAuthUser {
  id: string;
  email: string;
  verifyPassword(password: string): Promise<boolean>;
}

このインターフェースの設計には、以下の重要な考慮点があります:

  1. 最小限の情報公開

    • id: 認証に必要なユーザー識別子
    • email: ログインと認証トークン生成に必要な情報
    • verifyPassword: パスワード検証に必要な機能
  2. 読み取り専用プロパティ

    • idとemailはgetterとして定義
    • 認証ドメインからユーザー情報の変更を防止
  3. 単一責任の原則

    • 認証に必要な機能のみを定義
    • ユーザー管理に関する機能は含まない

2. Auth Entityの修正

User Entityへの直接的な依存を削除し、認証に必要な情報のみを保持するように修正します:

// src/domains/auth/domain/entities/auth.entity.ts

import { Token } from '../value-objects/token.value-object';
import { AggregateRoot } from '@nestjs/cqrs';

export class Auth extends AggregateRoot {
  public static readonly TOKEN_VALIDITY_DAYS = 7;

  private readonly userId: string;
  private readonly userEmail: string;
  private readonly token: Token;

  private constructor(userId: string, userEmail: string, token: Token) {
    super();
    this.userId = userId;
    this.userEmail = userEmail;
    this.token = token;
  }

  public static create(userId: string, userEmail: string, token: Token): Auth {
    if (!userId) {
      throw new Error('User ID is required');
    }
    if (!userEmail) {
      throw new Error('User email is required');
    }
    return new Auth(userId, userEmail, token);
  }

  public getToken(): Token {
    return this.token;
  }

  public getUserId(): string {
    return this.userId;
  }

  public getUserEmail(): string {
    return this.userEmail;
  }

  public isTokenExpired(): boolean {
    return this.token.isExpired();
  }
}

この実装には以下の特徴があります:

  1. 必要最小限の情報保持

    • userId: ユーザーの識別子
    • userEmail: 認証に必要なメールアドレス
    • token: 認証トークン
  2. 不変性の保証

    • すべてのプロパティがreadonly
    • コンストラクタでの一度きりの初期化
    • 値の変更を防ぐgetterメソッド
  3. ドメインの独立性

    • User Entityへの依存を完全に排除
    • 認証に必要な情報のみを保持

3. アダプターの実装

User DomainとAuth Domainの依存関係を解決するアダプターを実装します:

// src/domains/auth/infrastructure/adapters/auth-user.adapter.ts

import { IAuthUser } from '../../domain/interfaces/auth-user.interface';
import { User } from '../../../user/domain/entities/user.entity';

export class AuthUserAdapter implements IAuthUser {
  private constructor(
    private readonly _id: string,
    private readonly _email: string,
    private readonly _user: User,
  ) {}

  get id(): string {
    return this._id;
  }

  get email(): string {
    return this._email;
  }

  public static fromUser(user: User): AuthUserAdapter {
    const id = user.getId();
    if (!id) {
      throw new Error('User ID is required for authentication');
    }

    return new AuthUserAdapter(
      id.toString(),
      user.getEmail().getValue(),
      user,
    );
  }

  async verifyPassword(password: string): Promise<boolean> {
    return this._user.verifyPassword(password);
  }
}

このアダプターの実装には以下の重要な設計判断があります:

  1. プライベートコンストラクタ

    • インスタンス生成をfromUser静的メソッドに限定
    • 不正なインスタンス生成を防止
    • 生成時の検証を一箇所に集中
  2. 値のキャッシュ

    • コンストラクタで必要な値を変換・保持
    • 重複した変換処理を回避
    • パフォーマンスの最適化
  3. カプセル化

    • プライベートな_userプロパティ
    • 必要な操作のみを公開
    • User Entityの実装詳細を隠蔽
  4. 型安全性

    • コンパイル時の型チェック
    • ランタイムでの値の検証
    • 不正な値の使用を防止

4. リポジトリの修正

AuthRepositoryインターフェースをIAuthUserを使用するように修正します:

// src/domains/auth/domain/repositories/auth.repository.interface.ts

import { Auth } from '../entities/auth.entity';
import { IAuthUser } from '../interfaces/auth-user.interface';

export interface IAuthRepository {
  validateCredentials(email: string, password: string): Promise<IAuthUser>;  // IAuthUser型を使用
}

この修正には以下の意図があります:

  1. 依存関係の逆転

    • User Entityへの直接的な依存を削除
    • インターフェースを通じた間接的な依存
  2. 責務の明確化

    • 認証に関する操作のみを定義
    • ユーザー管理の責務を分離

改善点の解説

1. 依存関係の逆転

  • Before: Auth Domain → User Domain(直接的な依存)
  • After: Auth Domain ← AuthUserAdapter → User Domain(アダプターを介した依存)

この変更により:

  • Auth DomainはUser Domainの実装詳細を知る必要がなくなりました
  • 両ドメインが独立して進化できるようになりました
  • テストが容易になりました(アダプターをモック化可能)

2. 関心の分離

  • Auth Domain: 認証に関する責務のみを持つ
  • User Domain: ユーザー情報の管理に関する責務を持つ
  • AuthUserAdapter: ドメイン間の変換と情報の隠蔽を担当

各コンポーネントの責務が明確になり:

  • コードの保守性が向上
  • 変更の影響範囲が限定的に
  • 機能の追加や修正が容易に

3. クリーンアーキテクチャの原則の遵守

  1. 依存性の方向

    • 内側のレイヤー(ドメイン)は外側のレイヤーを知らない
    • 依存性は内側に向かう
  2. インターフェースの分離

    • 各ドメインは必要最小限のインターフェースのみを定義
    • 実装の詳細は外側のレイヤーに委ねる

結論

このリファクタリングにより、以下の目標を達成することができました:

  1. ドメインの独立性

    • 各ドメインが独立して進化可能
    • 実装の詳細が他のドメインに漏れない
    • テストが容易
  2. 保守性の向上

    • コードの疎結合化
    • 変更の影響範囲の限定
    • 責務の明確な分離
  3. 拡張性の確保

    • 新機能の追加が容易
    • インターフェースを通じた柔軟な実装変更
    • テスト容易性の向上

DDDの原則に従いながら、実用的で保守性の高いコードを実現することができました。この設計により、将来の要件変更や機能追加にも柔軟に対応できる基盤を整えることができます。

codeciaoテックブログ

Discussion