♻️
[NestJS] DDDアプローチによる認証機能の実装改善
本記事では、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. ロジック配置の判断基準
-
ユースケース依存性
- 特定のユースケースに依存 → Application層
- ドメインの本質的なルール → Domain層
-
外部サービス連携
- 外部サービスとの連携が必要 → Application層
- 純粋なドメインロジック → Domain層
-
ビジネスルールの性質
- ビジネスの本質的なルール → 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アプリケーションにも適用可能で、より保守性の高いコードベースの実現に貢献します。
Discussion