본문으로 건너뛰기
ElastiCORE 3.0 은 릴리스 후보(RC)입니다: Maven Central 의 3.0.261022-rc1. 2.2 라인 프로젝트는 2.2 문서를 보세요.
버전: 3.0

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)개발자가 쓰는 업무 메서드
SalesOrder.java (내 것)
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루트의 메서드
@behaviorprotected받지 않음 (READ_ONLY)받지 않음루트의 메서드
@readonly / @noinputprotected (@editable 루트는 public)받지 않음받지 않음서버 코드: 새 루트는 creation(), 그 뒤는 루트의 메서드
@editablepublic받음받음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, @minsizeDTO 의 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, @computederive(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(매퍼가 식별자를 참조로 바꿀 때만 사용)를 받습니다.

패키지 배치​

env.yml - DDD 레이어드 프리셋
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' 로 빌드하며, 헥사고날은 이 페이지의 모든 것을 포함합니다.