express만 사용하다 nest.js를 도입하기로 한 후 수박 겉핥기 식으로 공부해서 프로젝트에 적용시켰는데,
그러다보니 이렇다 할 구조도, 체계도 없이 중구난방인 프로젝트가 되어버려서
프로젝트 리뉴얼 하는 김에 도메인 주도 개발(이하 DDD)를 도입하기로 했다.
DDD는 복잡한 도메인을 효과적으로 이해하고, 비즈니스 로직과 도메인을 효율적으로 분리할 수 있게 하는 아키텍쳐이다.
데이터베이스에서 내 정보를 찾는 간단한 플로우로 예시를 들면서 알아보자
DDD의 구조를 요약하면 다음과 같다.
프레젠테이션 계층 (Presentation Layer)
- Controller
- DTO (Data Transfer Object)
애플리케이션 계층 (Application Layer)
- UseCase Class / Service Class: 도메인 로직을 호출하고, 트랜잭션을 관리하며, 여러 도메인 객체를 조정한다.
도메인 계층 (Domain Layer)
- Entity
- Repository Interface
- Value Object (VO)
인프라스트럭쳐 계층 (Infrastructure Layer)
- 외부 API 및 DB Config
- Repository Implementation
- presentation
- HTTP 요청 처리, 응답 반환
- Controller
- Controller
import { Controller, Get, Query } from '@nestjs/common';
import { UserService } from './user.service';
import { UserIdDto } from './dto/user-id.dto';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get('my-info')
async myInfo(@Query('id') id: UserIdDto) {
return await this.userService.myInfo(id);
}
}
- DTO
export class UserIdDto {
userId: string;
}
2. application
- 비즈니스 로직 구현, 프레젠테이션 - 도메인 계층 연결
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from 'src/domain/entities/user.entity';
import { UserRepository } from 'src/domain/repositories/user.repository';
import { UserIdDto } from '../dto/user-id.dto';
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
) {}
async myInfo(dto: UserIdDto): Promise<User | undefined> {
return await this.userRepository.findOne(dto.userId);
}
}
3. domain
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
userId: string;
}
- repository 인터페이스 도메인과 인프라 스트럭쳐(구체적인 메서드)를 분리하기 위함, 구체적인 메서드는 인프라스트럭처에서 구현되고, 도메인 계층은 repository interface를 주입받아 사용
import { User } from '../entities/user.entity';
export interface UserRepository {
findOne(userId: string): Promise<User | undefined>;
}
- VO(View Object) 불변객체, 특정 속성의 집합
- DTO와는 다르게 고유 식별자가 없고, getter만 존재
// src/domain/value-objects/user-id.vo.ts
export class UserId {
constructor(private readonly userId: string) {
if (!this.isValid(userId)) {
throw new Error('Invalid User ID format');
}
}
get value(): string {
return this.userId;
}
// 동일성 검사
equals(other: UserId): boolean {
return this.userId === other.userId;
}
// 도메인 논리: 유효성 검사
private isValid(userId: string): boolean {
// 여기서 userId의 형식 유효성 검사를 수행할 수 있습니다.
return /^[a-zA-Z0-9_-]+$/.test(userId);
}
}
VO를 사용하는 이유
동일성 검사: VO는 단순한 값이 아니라 논리적으로 같은 값을 가지는지 확인할 수 있습니다.
불변성: VO는 한 번 생성되면 값을 변경할 수 없습니다.
도메인 논리: VO는 관련된 도메인 논리를 포함할 수 있습니다. 예를 들어, UserId가 특정 형식을 준수하는지 확인할 수 있습니다.
VO를 사용하지 않는 경우
간단한 데이터 구조: 데이터가 단순하고 복잡한 검증이나 로직이 필요하지 않은 경우.
불변성 및 동일성 검사가 필요 없는 경우: 값 자체의 변경 가능성과 단순한 값 비교로 충분한 경우.
4. infrastructure
- 데이터베이스/외부 시스템과 상호작용
- 데이터베이스 config
- 외부 API 연동
- Repository implement
@Injectable()
export class UserRepositoryImpl implements UserRepository {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>
) {}
async findOne(userId: string): Promise<User | undefined> {
return await this.userRepository.findOne({ where: { userId } });
}
}