도메인 주도 설계 철저 입문
도메인 주도 설계 철저 입문
도메인 주도 설계 철저 입문 을 보고 간단하게 정리한 내용이다.
이 책을 읽어보는것을 추천한다.
- 지식 표현을 위한 패턴
- 값 객체
- 엔티티
- 도메인 서비스
- 애플리케이션을 구성하는 패턴
- 리포지터리
- 애플리케이션 서비스
- 팩토리
- 지식 표현을 위한 고급 패턴
- 애그리게이트
- 명세
값 객체 (Value Object)
-
프로그래밍 언어에는 원시 데이터 타입(Primitive Type) 이 있고, 이 타입만 사용해서 개발이 가능하지만, 필요에 따라서 직접 타입을 정의해야 할 때가 있다
-
DDD 에서는 원시 타입만을 사용하지 않고, 도메인에 맞는 객체로 정의해서 사용한다
-
객체 자체를 하나의 값으로써 취급하기 때문에
값 객체라고 부른다. -
예시
class FullName {private firstName: string;private lastName: stringconstructor(firstName: string, lastName: string) {this.firstName = firstName;this.lastName = lastName;}}
성질
- 불변이다
- 교환 가능하다
- 등가성을 비교할 수 있다
장점
- 표현력이 증가된다
- 무결성이 유지된다
- 잘못된 대입을 방지한다
- 로직이 흩어지는걸 방지한다
정리
- 원시 타입만으로 개발을 할 수 있지만, 범용적이기 때문에 도메인에 맞게 표현하기 어렵다
- 값 객체를 사용하면 좀 더 명확하게 표현할 수 있다
- 중복 코드를 방지할 수 있고, 중요한 로직을 값 객체에 모아둘 수 있다
엔티티 (Entity)
-
엔티티는 도메인 모델을 구현한 도메인 객체를 의미한다.
- 값 객체와 달리 생애주기를 가지고 있고, 식별자를 가지고 있다
- 생애주기를 갖고 있지 않거나, 생애주기가 무의미하면 값 객체로 다루는것이 좋다
-
값 객체는 값만 같으면 동일한 데이터로 취급하지만, 엔티티는 식별자가 같아야 같은 데이터로 취급한다
-
예시
class UserId {public value: string;constructor(value: string) {this.value = value;}}class User {private readonly id: UserId;private name: string;constructor(id: UserId, name: string) {this.id = id;this.name = name;}}
성질
- 가변이다
- 속성이 같아도 구분할 수 있다
- 동일성을 통해 구별된다
정리
- 도메인에 대한 내용이 한 곳에 모여있기 때문에 도메인을 이해하는데 도움이 된다
- 도메인 요구 사항이 변경된 경우 반영하기 쉽다
도메인 서비스 (Domain Service)
- 값 객체나 엔티티는 도메인의 행동을 정의할 수 있지만, 도메인 모델이 스스로 해결하기 부자연스러운 경우 도메인 서비스를 이용해서 해결한다
예시
-
중복된 이메일은 등록할 수 없다는 도메인 규칙이 있을 경우 이 행위 자체를 도메인 모델에 담기에는 부자연스럽다
class User {exists(user: User): boolean {// ...}}const user = new User()user.exists(user) -
이런 경우 도메인 서비스를 이용하면 자연스럽게 해결할 수 있다
class UserDomainService {exists(user: User): boolean {// ...}}const userDomainService = new UserDomainService()const user = new User()userDomainService.exists(user);
주의사항
-
도메인 서비스로 대다수의 로직들을 작성할 수 있지만, 도메인 서비스는 도메인 모델에서 처리하기 부자연스러운 처리로만 제한해서 작성해야 한다
-
도메인 서비스를 남용하면 데이터와 행위가 여러군데로 흩어지기 때문에 도메인 모델의 유연성에 문제가 생길 수 있다
class UserDomainService {changeEmail(user: User, email: string): void {if (email.length <= 0) {throw new Error('이메일은 최소 1글자 이상이어야 한다.');}user.email = email;}} -
가능한 도메인 모델에 행위를 정의하고 도메인 서비스는 피하는것이 좋다
리포지터리 (Repository)
- 리포지터리는 도메인 객체를 저장하고 복원하는 역할을 담당한다
- 리포지토리의 책임은 객체의 퍼시스턴시까지이다
예시
class UserRepository {
findOne(id: UserId): User | null {
// ...
}
findOneByEmail(email: string): User | null {
// ...
}
save(user: User): void {
// ...
}
}
class ApplicationService {
createUser(email: string): User {
const userRepository = new UserRepository()
const user = new User(email)
if (userService.exists(user)) {
// ...
}
userRepository.save(user)
// ...
}
}
interface UserRepository {
find(name: UserName): User | null;
save(user: User): void;
}
정리
- ORM 에서 사용하는 엔티티와 도메인에서 얘기하는 엔티티는 완전 다른 객체이다
- 도메인의 엔티티와 ORM 에서 얘기하는 엔티티를 분리하지 않을 경우 특정 기술에 종속되거나 DB 와 코드가 결합되어 수정하기 어려워진다
- 리포지터리는 객체의 저장, 복원만 담당하기 때문에 그 이상의 역할을 담아서는 안된다
- 구체적인 구현보다는 인터페이스로 작성해두면 특정 인프라스트럭처에 의존하지 않게 작성할 수 있다
애플리케이션 서비스
- 애플리케이션 서비스는 유스케이스를 구현한다
- 애플리케이션은 이용자의 목적을 해결하기 위한 프로그램을 의미한다
- 애플리케이션 서비스는 사용자가 원하는 기능을 구현하기위해 도메인 서비스, 리포지터리, 도메인 객체 등을 조합해서 수행한다
예시
- 유스케이스 예시
- 사용자 등록하기
- 사용자 정보 수정하기
class GetUserDto {
constructor(
public readonly id: UserId,
public readonly email: string
)
}
class UserApplicationService {
constructor(
private readonly userRepository: UserRepository,
private readonly userDomainService: UserDomainService,
)
register(email: string) {
const user = new User(email);
if (this.userDomainService.exists(user)) {
throw new Error('이미 가입된 사용자입니다');
}
this.userRepository.save(user);
}
get(email: string): User | null {
const user = this.userRepository.findOneByEmail(email);
if (!user) {
throw new Error('유저 정보가 없습니다');
}
return GetUserDto(user.id, user.email)
}
}
정리
-
도메인 객체를 직접 공개하는 경우 외부에서 데이터를 조작할 수 있기 때문에 직접 노출하지 않고 DTO (Data Transfer Object) 를 만들어서 전달하는것을 권장한다
-
데이터를 변경하는 경우 파라미터로 직접 다 받는것보다는 커맨드 객체를 만들어서 받는것이 좋다
class UpdateUserCommand {constructor(public readonly email: string)}class UserApplicationService {update(command: UpdateUserCommand): void {// ...}} -
애플리케이션 서비스는 도메인 객체가 수행하는 작업들을 조율하기만 해야 하며, 도메인 규칙이 노출되지 않게 해야한다
-
애플리케이션 서비스를 작성할 때는 응집도에 신경을 쓰는것이 바람직하다
- 사용자와 관련된 유스케이스라고 해서 꼭 한 클래스에 모아둘 필요는 없다
- 유스케이스마다 클래스를 반드시 분리할 필요도 없지만, 클래스를 구성하는 변수와 메소드가 적절히 대응되어 있는지 검토해보면 좋다
class UserRegisterService {// ...}class UserDeleteService {// ...} -
서비스는 어떠한 영역에도 존재할 수 있으며 상태를 가지고 있으면 안된다
- 상태를 가지고 있을 경우 복잡성이 증가하고 혼란스럽게 만들 수 있기 때문에 무상태를 유지할 수 있는 방법을 찾아보는것이 좋다
팩토리 패턴 (Factory Pattern)
-
팩토리는 객체를 만드는 지식에 특화된 객체이다
class User {private readonly id: UserId;private name: UserName;constructor(id: UserId, name: UserName) {// ...}}class UserFactory {create(name: UserName): User {const id = new UserId(// ...);return new User(id, name);}} -
객체의 생성 과정이 복잡하거나, 데이터베이스의 식별키 (Primary key) 를 연동해서 ID 에 부여해야할 때 사용할 수 있다
-
팩토리는 클래스 자체가 아니라 메서드가 역할을 하는 경우도 있다
class Circle {constructor(public userId: UserId,public name: CircleName);}class User {private id: UserId;createCircle(circleName: CircleName): Circle {return new Circle(this.id, circleName);}} -
객체의 생성 절차가 간단하다면 생성자를 통해서 만드는것이 더 낫다
-
팩토리를 이용해 객체 생성 절차를 캡슐화하는 것도 로직의 의도를 더 명확히 드러내면서 유연성을 확보할 수 있는 좋은 방법이다
데이터 무결성 보장
- 데이터의 무결성을 보장하기 위해서는 여러가지 방법들이 있다
- 데이터베이스 유니크 인덱스 제약조건(Unique Index Constraint)
- 강력하게 제한할 수 이씾만, 코드에 관련된 내용이 없기 때문에 알 수 없다
- 유일 키 제약 기능은 특정 데이터베이스의 기술이기 때문에 의존성이 생긴다
- 데이터베이스 트랜잭션
- 트랜잭션은 서로 의존적인 조작을 한꺼번에 완료하거나 취소하는 방법으로 무결성을 지킨다
- 관점 지향 프로그래밍 (AOP, Aspect Oriented Programming)
- 유닛 오브 워크 (UOW, Unit Of Work)
- 어떤 객체의 변경 사항을 기록하는 객체이다
- 퍼시스턴시 대상이 되는 객체의 생성, 변경, 삭제 등의 모든 동작이 모두 유닛오브워크를 통하게 된다
애그리거트 (Aggregate)
- 여러 개의 객체가 모여 한 가지 의미를 갖는 하나의 객체가 되는데, 이 객체들을 묶어서 표현하는것을 애그리거트(Aggregate) 라고 한다
- 애그리거트의 중점이 되는 객체를 애그리거트 루트 (Aggregate Root) 라고 한다
예시
class UserId {
consturctor (public readonly value: number) {}
}
class UserName {
constructor (public readonly value: string) {}
}
class User {
public id: UserId;
public name: UserName;
}
- 위의 예시에서
User가 애그리거트 루트 (Aggregate Root) 가 된다
규칙
-
애그리거트에 포함된 객체를 조작할 때는 항상 애그리거트 루트를 통해서 수정해야 한다
const userName = new UserName("Claude")user.Name = userName; // 이렇게 사용하면 안된다user.changeName(userName); // 이렇게 사용해야 한다 -
데메테르 법칙
- 객체 간의 메소드 호출에 질서를 부여하기 위한 가이드라인이다.
- 어떤 컨텍스트에서 다음 객체의 메서드만을 호출할 수 있게 제한한다
- 객체 자신
- 인자로 전달받은 객체
- 인스턴스 변수
- 해당 컨텍스트에서 직접 생성한 객체
-
객체 내부의 데이터는 함부로 외부에 공개해서는 안된다
- 일반적으로는 리포리저티 객체 외에는 애그리거트 내부 데이터에 접근하지 않는 코드를 규칙으로 만들어 협의해서 한다
- 강제성을 두기 위해서는 노티피케이션 객체 (Notification Object) 를 이용한다
애그리거트 경계
- 애그리거트의 경계를 정하는 원칙 중 가장 흔히 쓰이는 방법은 변경의 단위이다.
- 애그리거트에 대한 변경은 해당 애그리거트에 맡기고, 퍼시스턴시 요청도 애그리거트 단위로 해야 한다
- 경계를 확실하게 하기 위해서는 객체 인스턴스 대신 식별자(id) 를 들고 있으면 된다
- 이 방법은 메모리를 절약하는 부수적인 효과가 있다
- 애그리거트 (Aggregate) 당 리포지터리 (Repository) 를 1개씩 만든다
- 애그리거트 크기는 가능한 작게 정하는게 좋다
예시
class Circle {
users: User[];
}
const user1 = new User();
const user2 = new User();
userRepository.save(user1);
userRepository.save(user2);
const circle = new Circle();
circle.join(user1)
circle.join(user2);
circleRepository.save(circle);
user2.name = new UserName('New Name');
userRepository.save(user2);
- 위 코드로 인해 circle 안에 있는 user2 의 데이터가 변경되었으나, circle 안에는 user2 가 변경된 상태로 저장되어 있지 않기 때문에 복원할 경우 이전 데이터로 불러온다
명세 (Specification)
- 명세는 어떤 객체가 그 객체의 평가 기준을 만족하는지 판정하기 위한 객체이다
- 명세는 엔티티나 값 객체가 리포지터리를 다루지 않으면서도 문제를 해결할 수 있다
- 도메인 규칙은 도메인 객체 안에 정의되어야 한다
- 명세는 단독으로 사용되기도 하지만 리포지터리와 조합해 사용할 수 있다
- 리포지터리에서 명세를 필터로 사용할 경우 항상 성능에 염두를 둬야 한다
예시
class CircleFullSepcification {
constructor(private readonly userRepository: UserRepository) {}
isSatisfiedBy(circle: Circle): boolean {
const users = this.userRepository.find(circle.members);
return users.filter(user => user.isPremium).length > 10
}
}
복잡한 쿼리는 읽기 모델로 처리
- 시스템의 애초 존재 의의는 사용자의 문제를 해결하는것이다
- 그 어떤 조건보다 이 조건이 최우선이다
- 복잡한 읽기 작업에서 성능 문제가 우려된다면 이러한 부분에 한해 도메인 객체의 제약을 벗어나도 된다
- 페이지네이션, 복잡한 JOIN 쿼리 등
- 쓰기 작업 (Command) 에서는 도메인 객체 등을 적극적으로 활용하지만, 읽기 작업 (Query) 에서는 그렇지 않은 경우가 많다
- CQS (Command-Query Separation), CQRS (Command-Query Responsibility Segregation) 등 이 있다
- 지연 실행 (Lazy Execution) 을 통해 리포지터리는 그대로 사용하면서 성능 문제를 해결할 수 있는 방법도 있다
class CircleSummaryService {
getSummaries(query: GetCircleSummary): GetCircleSummariesResult {}
}