LearningFacade BC에서 AxisAction의 coverage_status는 연결된 ActionMaterial의 상태에 따라 결정된다.
NO_MATERIAL — 자료 없음PARTIAL (학습중) — 자료는 있으나 일부만 완료COVERED (마스터) — 자료가 있고 전부 완료coverage_status는 DB 컬럼으로 저장하기로 결정했기 때문에 (runtime 계산이 아닌 snapshot), 값을 갱신하는 주체와 계산 로직이 어디에 살아야 하는지가 문제가 됐다.
AxisAction에 계산 로직 넣기public class AxisAction {
public void recalculateCoverage(List<ActionMaterial> materials) {
// AxisAction이 자기 컬렉션도 아닌 목록을 외부에서 주입받음
// ActionMaterial은 @ManyToMany가 아닌 explicit join entity로 분리된 상태
// → AxisAction aggregate가 ActionMaterial 전체를 알아야 하는 구조가 됨
}
}
AxisAction은 ActionMaterial을 연관관계로 들고 있지 않다. countByActionId() 같은 query 기반 조회를 위해 일부러 explicit entity로 분리했기 때문이다. 그런 AxisAction이 List<ActionMaterial>을 파라미터로 받는 건 aggregate 경계를 어기는 냄새가 난다.
ActionMaterial에 static methodpublic class ActionMaterial {
public static CoverageStatus calculate(List<ActionMaterial> materials) {
// ActionMaterial이 AxisAction의 상태를 결정하는 주체?
// 책임이 반대방향으로 흐른다
}
}
ActionMaterial은 자기 완료 여부만 알면 된다. 다른 ActionMaterial들을 훑어서 AxisAction의 상태를 정하는 역할은 ActionMaterial의 책임이 아니다.
→ 두 엔티티 모두 어색. 전형적인 도메인 서비스 시그널.
public class CoverageCalculationService {
public CoverageStatus calculate(AxisAction action, List<ActionMaterial> materials) {
if (materials.isEmpty()) {
return CoverageStatus.NO_MATERIAL;
}
boolean allCompleted = materials.stream()
.allMatch(ActionMaterial::isCompleted);
return allCompleted
? CoverageStatus.COVERED
: CoverageStatus.PARTIAL;
}
}
지킨 원칙 두 가지(도메인 서비스에서 중요한 원칙):
@Service
@Transactional
@RequiredArgsConstructor
public class UpdateActionMaterialService implements UpdateActionMaterialUseCase {
private final LoadAxisActionPort loadAction;
private final LoadActionMaterialPort loadMaterials;
private final SaveAxisActionPort saveAction;
private final CoverageCalculationService coverageCalculator
= new CoverageCalculationService();
@Override
public void handle(UpdateActionMaterialCommand command) {
// 1. 조회 — Application Service 책임 (Port 경유)
AxisAction action = loadAction.loadById(command.actionId());
List<ActionMaterial> materials
= loadMaterials.loadByActionId(command.actionId());
// 2. 계산 — Domain Service 책임
CoverageStatus newStatus = coverageCalculator.calculate(action, materials);
// 3. 상태 변경 — Entity 자신의 책임
action.updateCoverageStatus(newStatus);
// 4. 저장
saveAction.save(action);
}
}
역할 분리가 선명해진다.
| 레이어 | 책임 |
|---|---|
| Application Service | Port 경유 데이터 조회/저장, 트랜잭션 |
| Domain Service | 여러 엔티티 협력이 필요한 계산 |
| Entity | 자기 상태 변경 |
class CoverageCalculationServiceTest {
private final CoverageCalculationService sut = new CoverageCalculationService();
@Test
void calculate_whenNoMaterial_returnsNoMaterial() {
// given
AxisAction action = DomainFixture.axisAction();
List<ActionMaterial> materials = List.of();
// when
CoverageStatus result = sut.calculate(action, materials);
// then
assertThat(result).isEqualTo(CoverageStatus.NO_MATERIAL);
}
@Test
void calculate_whenAllCompleted_returnsCovered() {
// given
AxisAction action = DomainFixture.axisAction();
List<ActionMaterial> materials = List.of(
DomainFixture.completedMaterial(),
DomainFixture.completedMaterial()
);
// when
CoverageStatus result = sut.calculate(action, materials);
// then
assertThat(result).isEqualTo(CoverageStatus.COVERED);
}
@Test
void calculate_whenPartiallyCompleted_returnsPartial() {
// given
AxisAction action = DomainFixture.axisAction();
List<ActionMaterial> materials = List.of(
DomainFixture.completedMaterial(),
DomainFixture.uncompletedMaterial()
);
// when
CoverageStatus result = sut.calculate(action, materials);
// then
assertThat(result).isEqualTo(CoverageStatus.PARTIAL);
}
}
@SpringBootTest, @DataJpaTest 전부 불필요. 순수 Java 객체 생성만으로 끝난다. 실행 속도는 체감상 즉시.
Q1. 단일 엔티티 데이터만으로 로직이 완성되는가?
YES → 엔티티 메서드
NO → 다음 질문
Q2. 두 엔티티 이상의 데이터가 필요한가?
YES → 도메인 서비스 후보
Q3. 엔티티에 넣으면 엔티티 간 결합도가 높아지는가?
YES → 도메인 서비스로 분리
Third Tool에서 이 기준을 적용해 뽑힌 도메인 서비스 후보:
CoverageCalculationService — AxisAction + ActionMaterial 목록 기반 coverage 계산OnFieldPromotionPolicy (예정) — UserScheduleConfig의 budget + 현재 ON_FIELD Card 집계 기반 승격 허용 여부 판정ReviewSoftScheduleAdvancer (예정) — Card의 현재 SoftScheduleState + 방금 생성된 ReviewHistory 기반 다음 state 전이반대로 도메인 서비스로 뽑지 않은 것 도 기록해 둔다.
Card.submitReview(grade) — Card 자기 상태만 바꿈 → 엔티티 메서드DifficultyScore.adjust(delta) — 값 객체 내부 계산 → VO 메서드AxisActionDescription 단일 동사 검증 — 값 객체 불변식 → VO 생성자도메인 서비스는 "엔티티에 넣자니 어색한데, 값 객체는 아닌" 로직을 위한 피난처가 아니다. 반대로, 여러 엔티티가 협력해야 성립하는 정책을 명시적으로 드러내기 위한 장치다.
처음엔 AxisAction.recalculateCoverage(materials)로 넣어도 돌아가긴 한다. 그런데 그 순간 AxisAction은 자기 aggregate 밖의 컬렉션을 파라미터로 받는 애매한 책임을 짊어지게 되고, 훗날 coverage 정책이 바뀔 때 — 예를 들어 "자료 3개 이상일 때만 COVERED 인정" 같은 규칙이 생길 때 — AxisAction이 수정 대상에 포함된다.
도메인 서비스로 분리해 두면, 정책 변경은 CoverageCalculationService 한 클래스에서 끝난다. Aggregate는 자기 상태 변경에만 집중하고, 정책은 정책대로 격리된다. 이게 DDD에서 도메인 서비스를 별도 레이어로 둘 만한 이유라고 느꼈다.