DDD (ddd-springboot)
architecture = 'ddd' 는 모델을 도메인 모델로 읽습니다: 루트가 있는 애그리거트, 루트를 통해 닿는 멤버, 값 객체,
다른 애그리거트는 ID 로 참조, 루트가 지키는 규칙, 루트가 내는 이벤트.
애그리거트 경계
| 쓰는 것 | 뜻 |
|---|---|
@root | 이 엔티티는 애그리거트 루트 |
@aggregate(Order) | 이 엔티티는 Order 애그리거트의 멤버 |
| (없음) | @root 엔티티가 List<X> @OneToMany(...)(또는 @cascade)로 가졌으면 그 루트의 멤버, 아니면 혼자 쓰이는 루트 |
멤버는 repository·service·controller·service 테스트를 받지 않습니다 - 애그리거트는 루트를 통 해 읽고 저장합니다.
@root 가 꼭 필요한 곳은 다른 루트가 목록으로 갖고 있지만 자기도 루트인 엔티티뿐입니다(Customer.orders: List<Order>).
멤버 쓰기는 루트를 거칩니다: 게터는 읽기 전용 뷰를 돌려주고(order.getItems().add(x) 는
UnsupportedOperationException), 생성된 addOrderItem / removeOrderItem / replaceItems 를 씁니다.
루트는 기본으로 잠긴다 - @editable 로 연다
루트의 필드는 잠깁니다: 요청은 루트를 만들 때만 값을 주고, 그 뒤로는 루트의 메서드가 바꿉니다. 루트는 두 파일로 나뉩니다:
| 파일 | 성격 | 내용 |
|---|---|---|
SalesOrderBase | 재생성 (@MappedSuperclass, abstract) | 필드, 이벤트 발생 메서드, 컬렉션·불변식 배관 |
SalesOrder | 한 번만 생성 (@Entity, extends SalesOrderBase) | 개발자가 쓰는 업무 메서드 |
public class SalesOrder extends SalesOrderBase {
public void confirm() {
if (getStatus() != OrderStatus.DRAFT) throw new InvariantViolationException("초안만 확정");
setStatus(OrderStatus.CONFIRMED); // protected setter
raiseConfirmed();
}
}
| 필드 종류 | setter | 요청(POST) | 요청(PUT) | 바꾸는 방법 |
|---|---|---|---|---|
| 기본 (잠김) | protected | 받음 | 같은 값이면 통과, 다르면 422 | 루트의 메서드 |
@behavior | protected | 받지 않음 (READ_ONLY) | 받지 않음 | 루트의 메서드 |
@readonly / @noinput | protected (@editable 루트는 public) | 받지 않음 | 받지 않음 | 서버 코드: 새 루트는 creation(), 그 뒤는 루트의 메서드 |
@editable | public | 받음 | 받음 | setter, PUT |
meta: 줄의 @editable 은 루트 전체를 평범한 데이터로 만듭니다 - 품 목·거래처·코드 같은 기준정보. @editable 필드가
하나도 없는 루트는 PUT 이 아예 없습니다: 생성·조회·삭제와 명령뿐입니다.
코드에서 루트를 만들 때도 매퍼와 같은 길을 씁니다: order.creation().setOrderNo("SO-1")
(Kotlin: order.creation().orderNo = "SO-1").
다른 애그리거트는 ID 로 참조
SalesOrder:
fields:
partner: Partner @ManyToOne # 쓰는 그대로
@Column(name = "partner_partner_id")
@Setter(AccessLevel.PROTECTED) // 잠김 - 만들 때 정하고, 이후엔 메서드로
private Long partnerId; // 참조는 이 필드
/** Another aggregate, to read. The reference is partnerId; ... */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "partner_partner_id", insertable = false, updatable = false)
@Setter(AccessLevel.NONE)
private Partner partner; // 같은 컬럼의 읽기 전용 조인 - setter 없음
테이블은 바뀌지 않고, 애그리거트는 다른 루트의 식별자만 가집니다. 저장 요청은 partnerId 를 보내고, 필수 참조
(member: Member!)면 DTO 에서 그 필드가 필수입니다. 없는 행을 가리키는 partnerId 는 저장 전에 확인되어 404
Partner 99 does not exist(코드 reference.SalesOrder.partner)입니다. 객체 참조로 두려면 @objectref.
env.yml 의 referenceColumn: short 는 새 프로젝트에서 컬럼 이름을 partner_id 로 만듭니다.
루트의 도메인 규칙
| DSL | 생성물 | 레퍼런스 |
|---|---|---|
@invariant(items.size > 0, 'an order has at least one line') | OrderInvariants 리스너 - 행이 쓰일 때 검사 (422) | 엔티티 |
루트 필드의 @min, @minsize | DTO 의 400 에 더해 저장 시점에도 같은 검사 | 필드 |
@factory(place) | OrderFactory.place(...), 한 번만 생성되는 틀 | 엔티티 |
@specification(highValue, totalAmount > 100) | OrderSpecifications.highValue() - Predicate<Order> | 엔티티 |
@event(Placed(total), Shipped) | OrderPlacedEvent, raisePlaced(), 저장할 때 발행 | 파생 값과 이벤트 |
states: | ship(), canShip(), 거절된 이동은 409 | 생명주기 |
commands: | POST /{id}/<명령> 부터 Base 의 추상 메서드까지 | 생명주기 |
@snapshot, @compute | derive(lookup), 저장 전마다 호출 | 파생 값과 이벤트 |
domainService:, processManager: | 포트를 가진 재생성 XxxBase + 한 번만 생성되는 내 클래스 | 파생 값과 이벤트 |
리포지터리 포트
도메인이 좁은 인터페이스를 소유하고, Spring Data 구현은 인프라에 둡니다. 검색과 페이징은 응용 계층의 조회 포트로
가서, Pageable 과 Specification 이 도메인에 들어오지 않습니다:
// domain.repository - Spring 타입이 하나도 없습니다
public interface OrderRepositoryPort {
Optional<Order> findById(String orderId);
Order save(Order order);
void delete(Order order);
boolean existsById(String orderId);
}
// application.port.out - 검색·페이징
public interface OrderQueryPort {
List<Order> findAll(Specification<Order> spec);
Page<Order> findAll(Specification<Order> spec, Pageable pageable);
long count(Specification<Order> spec);
}
// infrastructure.persistence - 두 포트를 한 어댑터가 구현
public class OrderRepositoryAdapter implements OrderRepositoryPort, OrderQueryPort { ... }
응용 서비스는 포트 둘과 EntityManager(매퍼가 식별자를 참조로 바꿀 때만 사용)를 받습니다.
패키지 배치
namespace:
entity: com.acme.shop.domain.model
enumeration: com.acme.shop.domain.model
event: com.acme.shop.domain.event
domainService: com.acme.shop.domain.service
domainRepository: com.acme.shop.domain.repository
service: com.acme.shop.application
dto: com.acme.shop.application.dto
queryPort: com.acme.shop.application.port.out
port: com.acme.shop.application.port.out
control: com.acme.shop.interfaces.rest
repository: com.acme.shop.infrastructure.persistence
persistence: com.acme.shop.infrastructure.persistence
domainService 를 service 와 같은 패키지에 두지 마세요. 도메인 서비스는 도메인의 것이고 응용 서비스는 그 위
계층입니다.
선택 기능
env.yml의outbox: true: 이벤트를 같은 트랜잭션에서 outbox 테이블에 적고, 커밋 후 내OutboxPublisher빈으로 보냅니다.mapping: xml은 도메인 클래스에서 JPA 어노테이션을 걷어 내고 같은 매핑을META-INF/orm.xml에 씁니다.archTest: true는 계층 경계를 ArchUnit 테스트로 생성합니다.
예제: elasticore-template-ddd
(catalog, shop, billing - 컨텍스트를 넘는 프로 세스 매니저 포함). 이 예제는
architecture = 'hexagonal' 로 빌드하며, 헥사고날은 이 페이지의 모든 것을 포함합니다.