Search
🏗️

도메인 주도 설계의 사실과 오해 (4) 애그리거트 구현

Tags
Architcture
DDD
Study
Last edited time
2024/11/01 00:24
2 more properties
Search
도메인 주도 설계의 사실과 오해 - 요약 및 목차
도메인 주도 설계의 사실과 오해 - 요약 및 목차

1. 애그리거트 경계 정하기

1.1. 애그리거트 설계

기능 구현과 불변식을 고려하여 설계
경계는 어떻게 정하냐?
보통 크게 얘기하는 단어들

1.2. 배달앱 예제 불변식 분석

1.2.1. 도메인 객체와 불변식

불변식
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
Menu 안에서 OptionGroup의 이름은 중복되지 말아야 한다.
OptionGroup에는 Option이 한 개 이상 존재해야 한다.

1.2.2. 기능 (1) 메뉴 공개하기

메뉴를 공개하기 위해서는 아래 4가지 불변식을 반드시 만족시켜야함
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
불변식 위반
OptionGroup이 존재하지 않는 경우
판매 중인 메뉴에 있는 모든 Option 가격이 0원인 경우
필수 OptionGroup이 존재하지 않는 경우
필수 OptionGroup이 네 개 이상 존재하는 경우

1.2.3. 기능 (2) 옵션 그룹 추가하기

옵션 그룹을 추가하기 위해서는 아래 2가지 불변식을 반드시 만족시켜야함
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
Menu 안에서 OptionGroup의 이름은 중복되지 말아야 한다.
불변식 위반
필수 OptionGroup이미 3개 존재하는데, 필수 옵션 그룹을 추가하려는 경우
동일한 옵션 그룹 이름으로 신규 옵션 그룹을 추가하려는 경우

1.2.4. 기능 (3) 옵션 그룹 삭제하기

옵션 그룹을 삭제하기 위해서는 아래 3가지 불변식을 반드시 만족시켜야함
판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
불변식 위반
판매 중인 OptionGroup이 존재하지 않는 경우
남은 Option 가격이 모두 0원인 경우
남은 OptionGroup이 전부 필수가 아닌 경우

1.3. 배달앱 예제 애그리거트 설계

불변식 기반으로 애그리거트 경계 결정

1.3.1. 애그리거트 경계 및 루트 정하기

경계
1.
메뉴 - 옵션그룹 - 옵션
2.
가게
루트
메뉴 & 가게
루트는 애그리거트 전체의 불변식을 보장

1.3.2. 엔티티와 값 객체 정하기

식별성, 연속성, 속성 등을 고려하여 설정

1.3.3. 연관관계 설정 및 구현

연관관계는 기능을 구현하는데 적합한 방향으로 결정

2. 애그리거트 구현하기

2.1. 애그리거트 루트

Base AggregateRoot
public abstract class AggregateRoot <T extends DomainEntity<T, TID>, TID> extends DomainEntity<T, TID> { }
Java
복사
Menu Aggregate
public class Menu extends AggregateRoot<Menu, MenuId> { private MenuId id; // 외부는 ID private ShopId shopId; private String name; private String description; private boolean open; // 내부 엔티티는 객체 참조 private Set<OptionGroup> optionGroups = new HashSet<>(); public Menu(ShopId shopId, String name, String description) { this(null, shopId, name, description, false, new HashSet<>()); } }
Java
복사
Shop Aggregate
public class Shop extends AggregateRoot<Shop, ShopId> { private ShopId id; private String name; private Money minOrderPrice; // 내부 값 객체는 객체 참조 private Map<DayOfWeek, TimePeriod> operatingHours; public Shop( String name, boolean open, Money minOrderPrice, Map<DayOfWeek, TimePeriod> operatingHours) { this(null, name, open, minOrderPrice, operatingHours); } }
Java
복사

2.2. 기능 (1) 메뉴 공개하기

불변식 통제는 루트의 책임
Menu Aggregate
기능은 애그리거트 내부의 객체들 사이의 협력으로 구현
책임 주도 설계

2.2.1. 불변식

판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.

2.2.2. 구현

Menu Aggregate
메뉴 판매 시작 open
불변식 validation 진행
countOfFreeOptionGroups
OptionGroup Entity에 협력 요청
public class Menu extends AggregateRoot<Menu, MenuId> { public void open() { if (optionGroups.isEmpty()) { throw new IllegalStateException( "옵션그룹은 하나 이상 존재해야 합니다."); } if (countOfFreeOptionGroups() < 1) { throw new IllegalStateException( "금액이 설정된 옵션그룹이 최소 1개는 등록되어 있어야 합니다."); } if (countOfMandatoryOptionGroups() == 0) { throw new IllegalStateException( "필수 옵션그룹은 최소 1개는 등록되어 있어야 합니다."); } if (countOfMandatoryOptionGroups() > 3) { throw new IllegalStateException( "필수 옵션그룹은 3개까지만 등록가능 합니다."); } open = true; } private long countOfFreeOptionGroups() { return optionGroups.stream().filter(group -> group.isFree()).count(); } }
Java
복사
OptionGroup Entity
isFree - 가격이 0원인 옵션이 있는지를 Option VO에게 협력 요청
@Getter public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { private OptionGroupId id; private String name; private boolean mandatory; private Set<Option> options = new HashSet<>(); public OptionGroup( OptionGroupId id, String name, boolean mandatory, Set<Option> options) { this.id = id; this.mandatory = mandatory; setName(name); setOptions(options); } private void setName(String name) { if (name == null || name.length() < 2) { throw new IllegalArgumentException( "옵션그룹명은 길이는 최소 2글자 이상이어야 합니다."); } this.name = name; } private void setOptions(Set<Option> options) { if (options == null || options.size() < 1) { throw new IllegalArgumentException( "옵션의 길이는 최소 1개 이상이어야 합니다."); } this.options.clear(); this.options = options; } public boolean isFree() { return options.stream().allMatch(Option::isFree); } }
Java
복사
Option Value Object
@Getter public class Option extends ValueObject<Option> { private String name; private Money price; public Option(String name, Money price) { if (name == null || name.length() < 2) { throw new IllegalArgumentException("옵션명은 2글자 이상이어야 합니다."); } if (price == null) { throw new NullPointerException("옵션 가격은 null이여서는 안됩니다."); } this.name = name; this.price = price; } public boolean isFree() { return Money.ZERO.equals(price); } @Override protected Object[] getEqualityFields() { return new Object[] { name, price }; } }
Java
복사

2.3. 기능 (2) 옵션 그룹 추가하기

2.3.1. 불변식

판매 중인 Menu에는 필수 OptionGroup이 세 개 이하로 존재해야 한다.
Menu 안에서 OptionGroup의 이름은 중복되지 말아야 한다.

2.3.2. 구현

OptionGroup Entity에 협력 요청
isMandatory
countOfMandatoryOptionGroups
public class Menu extends AggregateRoot<Menu, MenuId> { public void addOptionGroup(OptionGroup optionGroup) { if (optionGroup == null) { throw new IllegalArgumentException(); } if (isOpen() && optionGroup.isMandatory() && countOfMandatoryOptionGroups() >= 3) { throw new IllegalArgumentException("..."); } if (groups.stream().anyMatch( group -> group.getName().equals(optionGroup.getName()))) { throw new IllegalArgumentException("..."); } groups.add(optionGroup); } private long countOfMandatoryOptionGroups() { return groups.stream().filter(group -> group.isMandatory()).count(); } }
Java
복사

2.4. 기능 (3) 옵션 그룹 삭제하기

2.4.1. 불변식

판매 중인 Menu에는 OptionGroup이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 가격이 0원 이상인 Option이 한 개 이상 존재해야 한다.
판매 중인 Menu에는 필수 OptionGroup이 한 개 이상 존재해야 한다.

2.4.2. 구현

옵션그룹삭제 removeOptionGroup
불변식 validation 진행
public class Menu extends AggregateRoot<Menu, MenuId> { public void removeOptionGroup(OptionGroupId optionGroupId) { OptionGroup optionGroup = optionGroups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); if (!isOpen()) { optionGroups.remove(optionGroup); return; } if (optionGroups.size() == 1) { throw new IllegalArgumentException( "옵션그룹은 최소 1개는 등록되어 있어야 합니다."); } if (!optionGroup.isFree() && countOfFreeOptionGroups() == 1) { throw new IllegalArgumentException( "금액이 설정된 옵션그룹이 최소 1개는 등록되어 있어야 합니다."); } if (optionGroup.isMandatory() && countOfMandatoryOptionGroups() == 1) { throw new IllegalArgumentException( "필수 옵션그룹은 최소 1개는 등록되어 있어야 합니다."); } optionGroups.remove(optionGroup); } }
Java
복사
OptionGroup 엔티티 삭제 시 지역 식별자 이용하여 탐색
@Getter public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { // OptionGroup은 엔티티 private OptionGroupId id; ... } public class Menu extends AggregateRoot<Menu, MenuId> { // Menu 애그리거트는 지역 식별자를 이용하여 OptionGroup 탐색 public void removeOptionGroup(OptionGroupId optionGroupId) { OptionGroup optionGroup = optionGroups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); ... } }
Java
복사

2.4. 기능 (4) 옵션 그룹 & 옵션 이름 변경

2.4.1. 옵션 그룹 이름 변경

OptionGroup 기본 의 이름을 반찬추가 선택 으로 변경하고 싶다면?
OptionGroup은 엔티티로 모델링
public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { private OptionGroupId id; ... }
Java
복사
OptionGroup은 엔티티이므로 연속성과 식별성을 의미함
지역 식별자를 이용하여 엔티티를 찾은 후 이름 변경
public class Menu extends AggregateRoot<Menu, MenuId> { // 지역 식별자를 이용하여 해당 엔티티 탐색 public void changeOptionGroupName(OptionGroupId optionGroupId, String name) { if (groups.isEmpty()) { throw new IllegalArgumentException("옵션그룹이 비어있습니다."); } if (groups.stream().anyMatch(group -> group.getName().equals(name))) { throw new IllegalArgumentException("이름이 동일한 옵션그룹이 이미 존재합니다."); } // 지역 식별자를 이용하여 해당 엔티티 탐색 OptionGroup optionGroup = groups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); // 엔티티 자체의 상태 변경 optionGroup.changeName(name); } }
Java
복사
OptionGroup은 엔티티이므로 ID로 추적
@RestController public class MenuController { @PutMapping("/menus/{menuId}/optionGroups/{optionGroupId}") public void changeOptionGroupName(@PathVariable("menuId") Long menuId, @PathVariable("optionGroupId") Long optionGroupId, @RequestBody OptionGroupNameChangeRequest request) { menuService.changeOptionGroupName(new MenuId(menuId), new OptionGroupId(optionGroupId), request.getName()); } } class OptionGroupNameChangeRequest { private String name; // getter/setter ... }
Java
복사

2.4.2. 옵션 이름 변경

Option은 값 객체로 모델링
public class Option extends ValueObject<Option> { private String name; private Money price; ... }
Java
복사
Option의 이름을 변경하고 싶은 경우?
값 비교를 통해 동일한 객체 찾음
값 객체는 연속성이 없기 때문에 새로운 객체로 교체
Menu Aggregate
public class Menu extends AggregateRoot<Menu, MenuId> { // OptionGroup은 식별자로 탐색 // Option 값 객체는 값으로 탐색 public void changeOptionName(OptionGroupId optionGroupId, Option target, String optionName) { if (groups.isEmpty()) { throw new IllegalArgumentException("옵션그룹이 비어있습니다."); } // 지역 식별자를 이용해 OptionGroup 탐색 OptionGroup optionGroup = groups.stream() .filter(group -> group.getId().equals(optionGroupId)) .findFirst() .orElseThrow(() -> new IllegalArgumentException()); // 엔티티의 상태 변경 optionGroup.changeOptionName(target, optionName); } }
Java
복사
OptionGroup Entity
public class OptionGroup extends DomainEntity<OptionGroup, OptionGroupId> { // Option 값 객체는 값으로 탐색 public void changeOptionName(Option target, String optionName) { Option option = options.stream() // 값만 같으면 같은 객체 .filter(each -> each.equals(target)) .findFirst() .orElseThrow(IllegalArgumentException::new); // 기존의 Option 객체는 삭제 후 이름이 변경된 새로운 객체 추가 options.remove(option); options.add(target.changeName(optionName)); } }
Java
복사
Option Value Object
public class Option extends ValueObject<Option> { public Option changeName(String name) { // 새로운 객체 생성 return new Option(name, this.price); } }
Java
복사
Option의 이름 변경
MenuId, OptionGroupID는 엔티티 이므로 아이디로 추적
Option은 값 객체이므로 값으로 추적
@RestController public class MenuController { // 엔티티 이므로 아이디로 추적 @PutMapping("/menus/{menuId}/optionGroups/{optionGroupId}/options") public void changeOptionName(@PathVariable("menuId") Long menuId, @PathVariable("optionGroupId") Long optionGroupId, @RequestBody OptionNameChangeRequest request) { menuService.changeOptionName( new MenuId(menuId), new OptionGroupId(optionGroupId), // 값 객체이므로 Option 값을 이용하여 추적 new Option(request.getCurrentName(), Money.wons(request.getCurrentPrice())), request.getNewName()); } } class OptionNameChangeRequest { private String currentName; private Long currentPrice; private String newName; // getter/setter ... }
Java
복사

2.5. ORM Entity와 DDD Entity

ORM 엔티티로 DDD 엔티티 구현 가능
침투적이지 않음
테스트 쉽게 할 수 있는지? / 외부 의존성이 있는지?
레이지 로딩 (런타임에서 도는거) 신경써야함
성능 튜닝을 위한 뭔가를 밀어넣는순간 신경써야함
최대한쓰지말 것
엔티티를 분리하는 것은 선호하지 않음
함께 여러번 바꾸는게 힘들다