도메인 서비스 개념 글에서 규칙의 자리를 「그 규칙을 판단하려면 무엇을 봐야 하나」로 가르기로 했다. 애그리거트(함께 저장하고 함께 꺼내는 객체 묶음) 하나만 보면 되는 규칙은 그 애그리거트의 메서드에, 둘 이상을 같이 봐야 하는 규칙은 도메인 서비스에, DB에서 애그리거트를 꺼내고 다시 저장하는 순서는 XxxService에 둔다. 이 글을 쓰면서 둘째 기준을 한 번 더 좁혔다. 그 이유는 원칙 1에 있다.
그 글에서 우리 코드의 실제 사례도 찾았다. 둘이 나왔다.
- 관리사 배정 — 관리자가 돌봄 계약에 관리사를 배정할 때, 그 관리사가 이미 그만둔 사람이면 막아야 한다
- 사이드바 메뉴 거르기 — 관리자 화면의 사이드바에는 로그인한 관리자의 역할이 볼 수 있는 메뉴만 나와야 한다
두 규칙 모두 지금은 XxxService 안의 if로 들어 있다. 개념 글이 「규칙이 작업 순서 사이에 묻힌다」며 버린 자리다. 그렇다고 개념 글의 계좌 이체처럼 도메인 서비스로 떼어 낼 수도 없었다. 이 글은 그 이유에서 출발해 두 가지를 정한다.
- 다른 도메인의 사실이 필요한 규칙은 어디에 두나
- 도메인 서비스는 언제, 어디에, 어떤 이름으로 두나
먼저: 우리 구조에서 domain 패키지는 다른 도메인을 볼 수 없다
개념 글의 계좌 이체에서는 계좌 A와 계좌 B가 같은 도메인이었다. 그래서 도메인 서비스가 AccountDomain 둘을 그대로 받을 수 있었다.
실제 사례는 다르다. 관리사 배정은 돌봄 계약(carecontract)과 관리사(caregiver), 메뉴 거르기는 메뉴(adminmenu)와 관리자 역할(adminrole)에 걸친다. 모두 다른 도메인이다. 우리 구조는 계약 · 관리사 · 메뉴처럼 업무 단위마다 최상위 패키지를 하나씩 두고, 그 하나를 도메인이라고 부른다. 패키지가 다르면 다른 도메인이다.
패키지 구조 표준 원칙 4는 다른 도메인을 Service 계층에서만 만나게 한다. 도메인 패키지 하나(예: carecontract) 안은 다시 api · application · domain · infra 네 겹으로 나뉘는데, 그중 application이 Service 계층이고 domain은 CareContractDomain 같은 애그리거트가 사는 곳이다. 다른 도메인과 만날 때는 그 도메인의 Service 계층이 공개한 두 가지만 쓴다. XxxProvider는 「관리사 한 명 알려 줘」처럼 묻는 창구이고, XxxSnapshot은 그 답으로 돌아오는 읽기 전용 상자(그 순간의 관리사 정보를 베낀 것)다. 그래서 계약의 domain 패키지는 CaregiverDomain도 CaregiverSnapshot도 import할 수 없다. ArchUnit 검사가 그런 import를 막는다. 계약 쪽 domain에 도메인 서비스를 만들어도, 관리사를 인자로 받는 순간 이 규칙에 걸린다.
그래서 정해야 했던 것은 「도메인 서비스를 어디에 두나」가 아니라 「다른 도메인의 사실이 필요한 규칙을 어디에 두나」였다.
원칙 1. 규칙이 바꾸는 애그리거트가 하나면 그 애그리거트의 메서드가 판단하고, 다른 도메인의 사실은 값으로 받는다
약속: 반대로 정해도 동작은 같다. 통일이 목적이다.
(어느 자리에 두든 프로그램이 하는 일은 같고, 한 자리로 통일하는 것이 목적이라는 표시다. 그래서 실험으로 증명하지 않는다.)
관리사 배정을 예로 놓고 세 안을 비교했다. 정해야 했던 건 이거였다 — 「그만둔 관리사는 배정할 수 없다」를 누가 판단하나.
안 1 — XxxService가 판단한다 (지금 방식)
// CareContractService
CaregiverSnapshot caregiver = caregiverProvider.findByCaregiverNo(caregiverNo).orElseThrow(...);
if (!caregiver.isWorking()) {
throw ErrorCodeException.of(ErrorCode.CARE_CONTRACT_CAREGIVER_RESIGNED);
}
contract = contract.assign(caregiver.getCaregiverId(), ..., startedOn);
- 관리사 도메인의 창구(
caregiverProvider)에 관리사를 묻는다 - 일하는 중이 아니면 Service가 예외를 던진다
- 통과하면 계약에게 배정을 시킨다
얻는 것은 지금 코드를 그대로 둔다는 것이다. 계약은 관리사에 대해 아무것도 몰라도 된다.
버린 이유는 배정 규칙이 두 곳으로 흩어진다는 것이다. 계약의 assign 메서드는 이미 배정할 때의 규칙 셋을 스스로 지킨다. 배정 횟수 상한, 같은 관리사를 다시 배정하지 않기, 계약 기간 안의 날짜인가다. 「그만둔 관리사는 안 된다」도 「이 배정을 받아들일 수 있나」라는 같은 질문인데, 그것만 Service에 있다. 「배정할 때 무엇을 막나」를 알려면 assign과 Service를 둘 다 읽어야 한다. 계약을 만들면서 처음 배정하는 길이 따로 생기면 그쪽 Service에도 같은 if를 또 적어야 한다.
안 2 — 계약이 판단하고, 관리사 쪽 사실은 값으로 받는다
// CareContractService
CaregiverSnapshot caregiver = caregiverProvider.findByCaregiverNo(caregiverNo).orElseThrow(...);
contract = contract.assign(caregiver.getCaregiverId(), ..., startedOn, caregiver.isWorking());
// CareContractDomain.assign 안
if (!caregiverWorking) {
throw ErrorCodeException.of(ErrorCode.CARE_CONTRACT_CAREGIVER_RESIGNED);
}
- Service는 관리사를 묻는 데까지만 한다
- 「일하는 중인가」의 답(
true/false)을assign에 같이 넘긴다 - 판단과 예외는
assign안에서 한다. 원래 있던 규칙 셋 옆이다
얻는 것은 배정 규칙 넷이 한곳에 모인다는 것이다. 계약은 관리사 타입을 모른 채 boolean 하나만 받으므로, 「domain은 다른 도메인을 못 본다」도 그대로 지켜진다. assign은 DB 없이 단위 테스트로 시험할 수 있어서, 넷 모두 그 자리에서 시험할 수 있다.
안 3 — 계약 쪽 domain에 도메인 서비스를 따로 둔다
// carecontract/domain
public class CareContractAssignment {
public CareContractDomain assign(CareContractDomain contract, boolean caregiverWorking, ...) { ... }
}
얻는 것은 개념 글의 모양을 그대로 따른다는 것이다. 버린 이유는 떼어 낼 까닭이 없다는 것이다. 개념 글에서 도메인 서비스가 필요했던 건 계좌 이체가 A의 일도 B의 일도 아니어서였다. 배정은 계약 하나만 바뀌는 일이라 주인이 분명하다. 관리사는 사실 하나를 알려 줄 뿐 바뀌지 않는다. 그런데도 클래스를 떼면 배정 규칙이 assign과 CareContractAssignment 두 곳으로 다시 흩어진다.
골랐다 — 안 2. 안 1은 배정 규칙을 Service와 assign에 나누고, 안 3은 assign과 새 클래스에 나눈다. 안 2만 「이 배정을 받아들일 수 있나」를 assign 한곳에 모은다.
여기서 개념 글의 기준을 한 번 더 좁혔다. 개념 글은 「둘 이상을 같이 봐야 하면 도메인 서비스」라고 했다. 그 기준대로면 계약과 관리사를 같이 봐야 하는 배정도 도메인 서비스로 가야 한다. 하지만 배정에서 바뀌는 것은 계약 하나이고, 관리사는 「일하는 중인가」라는 사실만 알려 준다. 바뀌는 쪽이 하나면 그 일의 주인이 있다. 그래서 자리를 가르는 질문을 「무엇을 봐야 하나」에서 「무엇이 바뀌나」로 옮겼다. 바뀌는 애그리거트가 하나면, 다른 애그리거트가 같은 도메인이든 다른 도메인이든 바뀌는 쪽의 메서드가 판단한다. 같은 도메인이면 그 애그리거트를 인자로 그대로 받아도 되고, 다른 도메인이면 아래처럼 값으로 받는다. 개념 글의 계좌 이체는 A와 B가 둘 다 바뀌어서 주인이 없었고, 그래서 도메인 서비스였다. 이 기준으로 개념 글의 표와 용어 페이지도 맞췄다.
메뉴 거르기도 같은 모양이 된다. 이쪽은 무엇을 바꾸는 규칙이 아니라 조회할 때 거르는 규칙이라, 「무엇이 바뀌나」 대신 「무엇이 걸러지나」를 묻는다. 걸러지는 것은 메뉴 하나하나이고, 역할은 「최고 관리자인가 · 볼 수 있는 메뉴 목록」이라는 사실만 알려 준다. 그래서 판단은 메뉴의 메서드가 한다.
// AdminMenuService — 안 2 로 옮긴 모양
AdminRoleSnapshot role = adminRoleProvider.getByRoleCode(account.getRoleCode());
List<AdminMenuDomain> visible = new ArrayList<>();
for (AdminMenuDomain menu : menus) {
if (menu.isVisibleTo(role.isSuperAdmin(), role.getViewableMenuIds())) {
visible.add(menu);
}
}
- 로그인한 관리자 계정(
account)의 역할을 역할 도메인의 창구에 묻는다 - 메뉴마다
isVisibleTo에 「최고 관리자인가」와 「볼 수 있는 메뉴 목록」을 넘긴다. 보여 줘도 되는지는 메뉴가 판단한다 true인 메뉴만 모은다. Service에if가 남아 있지만, 이if는 역할 값을 직접 보지 않고 메뉴가 내린 판단(true/false)만 본다
넘기는 값은 boolean이나 컬렉션 같은 값, 또는 자기 도메인의 타입이다. CaregiverSnapshot을 통째로 넘기지 않는다. Snapshot은 관리사 도메인의 Service 계층 타입이라, 그걸 받으려면 계약의 domain이 관리사의 application을 import해야 한다. 그러면 관리사 쪽이 Snapshot을 고칠 때 계약의 애그리거트까지 같이 깨지고, 앞에서 본 ArchUnit 검사에도 걸린다.
찾는 대상이 아예 없을 때의 예외는 Service에 그대로 둔다. 앞 코드의 orElseThrow(...)가 그 자리다. 관리사를 찾았으면 꺼내 주고, 없으면 CAREGIVER_NOT_FOUND 예외를 던진다. 관리사가 없으면 판단할 재료 자체가 없는 것이라, 이건 재료를 가져오는 순서의 일이다. 반면 「그만뒀다」는 가져온 재료를 보고 내리는 판단이다.
대신 부르는 쪽이 엉뚱한 값을 넘기면 계약은 그걸 알아챌 수 없다. caregiver.isWorking() 대신 true를 넘겨도 assign은 통과시킨다. 다른 도메인에 묻는 일은 여전히 Service가 하므로, 그 답이 맞는지는 Service를 믿을 수밖에 없다. 그래도 안 1보다 나빠지지는 않는다. 안 1에서는 Service가 if를 통째로 빠뜨려도 아무도 모르지만, 안 2에서는 assign이 그 인자를 요구하므로 빠뜨리면 컴파일이 안 된다.
원칙 2. 같은 도메인 안의 애그리거트 둘이 함께 바뀌는 규칙만 도메인 서비스로 떼고, domain 패키지에 하는 일 이름으로 둔다
약속: 반대로 정해도 동작은 같다. 통일이 목적이다.
원칙 1로 다른 도메인이 끼는 규칙은 애그리거트 메서드로 갔다. 그러면 도메인 서비스가 필요한 경우는 하나만 남는다. 같은 도메인 안의 애그리거트 둘 이상이 함께 바뀌고, 어느 한쪽의 일이라 하기 어려운 규칙이다. 둘은 계좌 A와 계좌 B처럼 같은 종류일 수도 있고, 한 도메인 패키지 안에 사는 다른 종류의 애그리거트일 수도 있다. 개념 글의 계좌 이체가 앞의 모양이다. 「A에서 1만 원을 빼서 B에 더한다」는 A와 B가 함께 바뀌고, 어느 계좌의 일이라고 하기 어렵다. 그래서 AccountTransfer.transfer(from, to, amount)가 계좌 둘을 받아 빼고 더한 결과 둘을 돌려준다.
찾은 범위에서는 우리 코드에 이런 규칙이 없었다. 다른 종류의 애그리거트 둘을 쥐려면 Service가 Repository를 둘 이상 가져야 하는데, 그런 Service 8개는 모두 한 애그리거트의 저장용 · 검색용 Repository 짝이었다. 다만 같은 종류 둘(계좌 A와 B)은 Repository 하나로 두 번 꺼낼 수 있어서 이 방법으로는 걸리지 않는다. 그 꼴은 따로 찾지 않았다. 찾은 방법은 개념 글의 사례 섹션에 있고, 거기서 if 줄을 뽑아 찾은 탐색도 숫자 비교처럼 다른 꼴의 규칙은 놓쳤을 수 있다.
사례가 없는데도 지금 정해 두는 이유는, 첫 사례가 생겼을 때 사람마다 다른 자리와 이름으로 만들지 않게 하려는 것이다. 그래서 정한 것은 네 가지다.
- 자리는 그 도메인의
domain패키지다. 같은 도메인의 애그리거트만 받으므로 패키지 구조 표준과 부딪히지 않는다 - 이름은 하는 일 그대로 짓는다.
AccountTransfer처럼 짓고Service를 붙이지 않는다.AccountTransferService라고 지으면XxxService(꺼내고 저장하는 쪽)와 이름만으로는 구별이 안 된다 -
Spring 애노테이션(
@Service같은 표시)을 붙이지 않고 Repository(DB에서 꺼내고 저장하는 창구)를 받지 않는다. 이미 꺼낸 애그리거트를 받아 판단하고, 바뀐 애그리거트를 돌려준다. 저장은XxxService가 한다.domain패키지가 Spring을 모르게 하는 규칙은 패키지 구조 표준 원칙 6의 ArchUnit 검사가 이미 지킨다 XxxService가new로 만들어 필드에 둔다.@Service가 붙은 클래스는 Spring이 객체를 만들어 필요한 곳의 필드에 넣어 준다. 도메인 서비스에는 그 표시가 없으니 직접 만든다. 상태를 들지 않아서 하나를 계속 써도 된다
대가는 검증되지 않은 약속이라는 것이다. 첫 사례가 이 모양에 안 맞으면(예: 규칙을 판단하는 데 Repository 조회가 꼭 필요하면) 그때 이 원칙을 다시 연다.
그래서 이렇게 한다 — 도메인 서비스 표준의 규칙 파일
규칙의 자리는 「그 규칙이 무엇을 바꾸나」(조회에서 거르는 규칙이면 「무엇이 걸러지나」)로 정한다. 꺼내기 · 저장 · 트랜잭션 · 다른 도메인에 묻기 같은 순서는 XxxService에 두고, 판단은 domain 쪽에 둔다.
바뀌는 애그리거트가 하나인 규칙
- 바뀌는 쪽 애그리거트 루트(
XxxDomain)의 메서드가 판단한다. 다른 애그리거트는 판단에 쓸 사실만 넘긴다. 같은 도메인이면 그 애그리거트를 인자로 받아도 된다. - 다른 도메인의 사실은
XxxService가 그 도메인의XxxProvider로 묻고, 그 값을 메서드 인자로 넘긴다.contract.assign(caregiverId, ..., caregiver.isWorking())—assign안에서if (!caregiverWorking) throw ...menu.isVisibleTo(role.isSuperAdmin(), role.getViewableMenuIds())— 조회할 때 거르는 규칙도 같다
- 인자는 원시값 · 컬렉션 · 자기 도메인 타입으로 넘긴다. 다른 도메인의
XxxSnapshot을domain메서드의 인자로 쓰지 않는다.Snapshot은 그 도메인의 Service 계층 타입이라domain이 남의 Service 계층에 의존하게 된다. package-structure-standard를 따른다. XxxService의 조건식에서 다른 도메인의Snapshot값을 직접 보고 판단하지 않는다(예외를 던지는if, 목록을 거르는 조건). 판단은 애그리거트 메서드가 하고,XxxService는 그 결과(예외 ·boolean)만 쓴다.- 찾는 대상이 없을 때의 예외(
orElseThrow(... NOT_FOUND))는XxxService에 둔다. 재료를 가져오는 순서의 일이다.
도메인 서비스
약속: 반대로 정해도 동작은 같다. 통일이 목적이다.
- 아직 우리 코드에 사례가 없다. 첫 사례가 이 절에 안 맞으면 표준을 다시 연다.
- 같은 도메인 안의 애그리거트 둘 이상이 함께 바뀌고, 어느 한쪽의 일이라 하기 어려운 규칙만 도메인 서비스로 뗀다. 다른 도메인의 애그리거트 둘이 함께 바뀌는 규칙은 이 표준이 정하지 않는다.
- 그 도메인의
domain패키지에 둔다.- 이름은 하는 일 그대로 짓는다(
AccountTransfer).Service접미사를 붙이지 않는다.XxxService와 헷갈린다.- Spring 애노테이션을 붙이지 않고 Repository를 받지 않는다. 이미 꺼낸 애그리거트를 인자로 받아 판단하고, 바뀐 애그리거트를 돌려준다. 저장은
XxxService가 한다.- 상태를 들지 않으므로
XxxService가new로 만들어 필드에 둔다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 1. 이 규칙을 판단하려면 무엇을 봐야 하나 | 애그리거트 하나 | 그 애그리거트의 메서드 |
| 2. 둘 이상을 봐야 하면, 무엇이 바뀌나 (거르는 규칙이면 무엇이 걸러지나) | 애그리거트 하나 (다른 쪽은 사실만 알려 준다) | 바뀌는 쪽의 메서드. 다른 도메인의 사실은 값으로 받는다 (원칙 1) |
| 같은 도메인 안의 애그리거트 둘 이상이 함께 | 도메인 서비스 (원칙 2) | |
| 다른 도메인의 애그리거트 둘 이상이 함께 | 아직 안 정했다 (도메인 이벤트 줄기에서 본다) | |
다른 도메인의 Snapshot을 domain 메서드에 넘겨도 되나 |
안 된다. domain이 남의 Service 계층에 의존한다 |
값이나 자기 도메인 타입으로 넘긴다 |
| 찾는 대상이 없을 때의 예외는 | 재료를 가져오는 순서의 일이다 | Service에 둔다 |
| 도메인 서비스의 자리와 이름은 | 그 도메인의 domain, 하는 일 그대로 |
Service 접미사 · Spring 애노테이션 · Repository 없음 |
이 표준을 정하기까지
시작은 개념을 모른다는 것이었다. 「도메인 드리븐」이라 이름 붙인 우리 표준에 도메인 서비스가 없다는 안건(#180)이 있었는데, 개념부터 몰라서 고를 수가 없었다. 그래서 질문 셋으로 나눠 공부했다. 무엇인가(#198), 우리 코드에 있나(#199), 어디에 두나(#200, 이 글)다.
정의는 계좌 이체로 세웠다. 개념 글에서 「판단하려면 무엇을 봐야 하나」로 셋을 가르고, 계좌 둘에 걸친 규칙을 도메인 서비스로 떼는 모양을 잡았다.
실제 사례를 찾자 전제가 바뀌었다. 우리 코드에서 찾은 두 사례는 모두 다른 도메인에 걸쳐 있었다. 처음에는 한 사례만 찾았다고 했다가, 개념 글 검토 중에 if 목록을 다시 열어 메뉴 거르기를 더 찾았다. 계좌 이체는 같은 도메인 안의 이야기였는데, 우리 구조에서는 domain 패키지끼리 서로를 못 본다. 개념 글의 모양을 그대로 옮길 수 없었다.
그래서 물음을 바꿨다. 「도메인 서비스를 어디에 두나」가 아니라 「다른 도메인의 사실이 필요한 규칙을 누가 판단하나」로 물었다. 세 안을 놓고 보니 배정은 계약 하나만 바뀌는 일이었다. 주인이 있는 일이라 도메인 서비스가 필요 없었다. 계약의 assign에 이미 배정 규칙 셋이 모여 있었고, 넷째를 그 옆에 두는 안 2를 골랐다.
검토하다가 기준이 두 개로 갈려 있는 것을 찾았다. 개념 글은 「같이 봐야 하면」, 이 글은 「함께 바뀌면」이었다. 배정에서 실제로 쓴 기준은 뒤쪽이라, 원칙 1의 헤딩을 「무엇이 바뀌나」로 고치고, 판단 기준 표는 「무엇을 봐야 하나」를 먼저, 둘 이상이면 「무엇이 바뀌나」를 다음에 묻게 고치고 개념 글과 용어 페이지도 같이 맞췄다.
그러자 도메인 서비스가 들어갈 자리가 거의 사라졌다. 남은 것은 같은 도메인 안의 애그리거트 둘이 함께 바뀌는 경우뿐이고, 우리 코드에는 아직 없다. 사례가 없는 것을 정할지 따로 물었고, 첫 사례가 제각각 만들어지지 않게 자리 · 이름 · 쓰지 않는 것 세 가지만 정하기로 했다. 쓰다 보니 「Spring 애노테이션이 없으면 누가 만드나」가 따라 나와서 new로 만든다는 넷째가 붙었다. 대신 검증된 규칙이 아니라 「약속」으로 표시했다.
도메인 서비스 표준에서 아직 정하지 않은 것
- 지금 코드를 언제 옮기나. 관리사 배정과 메뉴 거르기는 아직 안 1의 모양이다. 이 표준은 새로 짜거나 고칠 때부터 걸린다. 두 곳을 따로 옮길지는 정하지 않았다
- 도메인 서비스가 Repository를 꼭 써야 하는 규칙이 나오면. 원칙 2는 「이미 꺼낸 것을 받는다」로 정했다. 판단 도중에 무엇을 더 찾아야 하는 규칙이 나오면 다시 연다
- 다른 도메인의 애그리거트 둘이 함께 바뀌는 규칙. 계약을 바꾸면서 관리사의 상태도 바꿔야 하는 경우다. 이 글의 원칙 어디에도 들지 않는다. 다음 줄기(도메인 이벤트, #201~#203)에서 「직접 부르나, 이벤트로 알리나」로 본다
- 규칙이
if하나뿐일 때도 도메인 서비스로 떼나. 개념 글에서 대가로 적었지만 기준은 세우지 않았다. 첫 사례가 생기면 함께 정한다
정리
- 자리는 「무엇이 바뀌나」로 정한다. 바뀌는 애그리거트가 하나면 그 메서드가 판단하고, 다른 쪽은 사실만 넘긴다. Service는 다른 도메인에 묻고, 그 답을 값으로 넘긴다
- 다른 도메인의
Snapshot은domain에 넘기지 않는다.boolean· 컬렉션 · 자기 도메인 타입으로 넘긴다 - 도메인 서비스는 같은 도메인 안의 애그리거트 둘이 함께 바뀌는 규칙에만 쓴다. 그 도메인의
domain패키지에, 하는 일 그대로의 이름으로, Spring과 Repository 없이 둔다 - 우리 코드에는 아직 도메인 서비스가 하나도 없다. 원칙 2는 첫 사례를 위한 약속이다
자신만의 철학을 만들어가는 중입니다.
댓글남기기