♻️

[NestJS] DDDアプローチによる認証機能の実装改善

2025/02/16に公開

本記事では、NestJSアプリケーションにおける認証機能のリファクタリング過程を解説します。ドメイン駆動設計(DDD)の原則に基づいて、認証ロジックをApplication層に適切に配置し、Value Objectsを活用した実装への改善を行います。このアプローチにより、保守性が高く、テストが容易なコードベースを実現します。

アーキテクチャの変更

リファクタリング前後のアーキテクチャの変更を図で表現すると:

【リファクタリング前】

Infrastructure層
└── AuthRepository
    └── validateCredentials()  // 認証ロジックが混在
        ├── データアクセス
        └── パスワード検証

【リファクタリング後】

Application層
└── AuthService
    └── validateCredentials()  // ユースケースとしての認証

Domain層
└── Value Objects
    ├── Password
    │   └── verify()          // パスワード検証ロジック
    └── Email

Infrastructure層
└── AuthRepository
    └── findByEmail()         // 純粋なデータアクセス

認証フローの設計

【ログイン認証フロー】

Client ──> LoginHandler ──> AuthService ──> AuthRepository
                │               │
                │               └──> Password.verify()
                │
                └──> TokenService
                └──> CookieService

1. LoginHandler: ユースケースの開始点
2. AuthService: 認証ロジックの調整
3. Password.verify(): ドメインロジック
4. TokenService/CookieService: インフラストラクチャサービス

実装の詳細

1. Value Objectsの活用

パスワードの検証ロジックをPassword Value Objectとして実装:

export class Password {
  private readonly hashedValue: string;

  private constructor(hashedPassword: string) {
    this.hashedValue = hashedPassword;
  }

  static fromHashed(hashedPassword: string): Password {
    return new Password(hashedPassword);
  }

  static async create(plainPassword: string): Promise<Password> {
    this.validatePassword(plainPassword);
    const hashedPassword = await bcrypt.hash(plainPassword, 10);
    return new Password(hashedPassword);
  }

  private static validatePassword(password: string): void {
    if (password.length < 8) {
      throw new Error('Password must be at least 8 characters long');
    }
  }

  async verify(plainPassword: string): Promise<boolean> {
    return bcrypt.compare(plainPassword, this.hashedValue);
  }

  getHashedValue(): string {
    return this.hashedValue;
  }
}

2. Application層の認証サービス

認証ロジックをApplication層のサービスとして実装:

@Injectable()
export class AuthService implements IAuthService {
  constructor(
    private readonly authRepository: IAuthRepository,
  ) {}

  async validateCredentials(email: Email, password: string): Promise<IAuthUser> {
    const user = await this.authRepository.findByEmail(email);
    if (!user) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const isValid = await user.password.verify(password);
    if (!isValid) {
      throw new UnauthorizedException('Invalid credentials');
    }

    return user;
  }
}

3. Infrastructure層のリポジトリ

データアクセスに特化したリポジトリの実装:

@Injectable()
export class AuthRepository implements IAuthRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findByEmail(email: Email): Promise<IAuthUser | null> {
    const prismaUser = await this.prisma.user.findUnique({
      where: { email: email.getValue() },
    });

    if (!prismaUser) {
      return null;
    }

    return {
      id: prismaUser.id.toString(),
      email: new Email(prismaUser.email),
      password: Password.fromHashed(prismaUser.password),
    };
  }
}

4. ユースケースハンドラ

LoginHandlerの実装:

@CommandHandler(LoginCommand)
export class LoginHandler implements ICommandHandler<LoginCommand> {
  constructor(
    @Inject(AUTH_SERVICE)
    private readonly authService: IAuthService,
    @Inject(TOKEN_SERVICE)
    private readonly tokenService: ITokenService,
    @Inject(AUTH_COOKIE_SERVICE)
    private readonly authCookieService: IAuthCookieService,
  ) {}

  async execute(command: LoginCommand) {
    try {
      const email = new Email(command.email);
      const user = await this.authService.validateCredentials(email, command.password);

      const token = await this.tokenService.generateToken(user);
      const auth = Auth.create(user.id, user.email, token);
      this.authCookieService.setAuthCookie(command.response, token);

      return {
        user: {
          id: user.id,
          email: user.email.getValue(),
        },
        accessToken: token.getValue(),
      };
    } catch (error) {
      console.error('Login error:', error);
      if (error instanceof UnauthorizedException) {
        throw error;
      }
      throw error;
    }
  }
}

設計原則とガイドライン

1. レイヤー間の責務分離

  • Domain層

    • パスワードのハッシュ化/検証
    • メールアドレスの形式検証
    • ドメインルールに関連する検証
  • Application層

    • 認証フローの制御
    • 外部サービスの連携(トークン、クッキー)
    • エラーハンドリング
  • Infrastructure層

    • データアクセス
    • 外部サービスとの統合
    • 技術的な実装の詳細

2. ロジック配置の判断基準

  1. ユースケース依存性

    • 特定のユースケースに依存 → Application層
    • ドメインの本質的なルール → Domain層
  2. 外部サービス連携

    • 外部サービスとの連携が必要 → Application層
    • 純粋なドメインロジック → Domain層
  3. ビジネスルールの性質

    • ビジネスの本質的なルール → Domain層
    • アプリケーション固有のフロー → Application層

メリットと成果

1. コードの品質向上

  • 責務の明確な分離
  • テスタビリティの向上
  • 保守性の改善

2. 拡張性の確保

  • 新しい認証方式の追加が容易
  • マルチファクタ認証の実装
  • ソーシャルログインの統合

3. テスト容易性

describe('AuthService', () => {
  let authService: AuthService;
  let authRepository: MockAuthRepository;

  beforeEach(() => {
    authRepository = new MockAuthRepository();
    authService = new AuthService(authRepository);
  });

  it('should validate valid credentials', async () => {
    const email = new Email('test@example.com');
    const password = 'correct-password';
    const user = {
      id: '1',
      email,
      password: await Password.create(password),
    };

    authRepository.findByEmail.mockResolvedValue(user);

    const result = await authService.validateCredentials(
      email,
      password,
    );

    expect(result).toEqual(user);
  });
});

まとめ

このリファクタリングにより、以下の改善を実現しました:

クリーンアーキテクチャの実現

  • 適切な層への責務の配置
  • 明確な依存関係の方向性

DDDベストプラクティスの適用

  • Value Objectsの効果的な活用
  • ドメインロジックの適切なカプセル化

保守性と拡張性の向上

  • テストが容易な構造
  • 変更に強い設計
  • 再利用可能なコンポーネント

このアプローチは、他の認証機能を持つNestJSアプリケーションにも適用可能で、より保守性の高いコードベースの実現に貢献します。

codeciaoテックブログ

Discussion