생명주기 (states:) 와 업무 명령 (commands:)
상태가 어디서 어디로만 바뀌는지는 코드 대신 DSL 에 씁니다. 명령 메서드의 본문까지 생성됩니다.
architecture = 'ddd' 나 'hexagonal' 이 필요합니다.
세 곳이 이렇게 연결됩니다
enumeration:
OrderStatus: # (1) 상태 목록 = enum 의 상수 이름
meta: enum @db(code) @json(code)
fields:
code: string(3) @code
label: string(20)
enum:
PLACED: PLC, Placed
SHIPPED: SHP, Shipped
CANCELLED: CAN, Cancelled
entity:
PurchaseOrder:
meta: entity @root @expose(20) @event(Placed(total), Shipped, Cancelled)
fields:
orderId: string(36) @id @genid
status: OrderStatus # (2) 상태를 담는 필드
states:
status: # (3) 키 = 필드 "이름"
initial: PLACED # 새 주문의 시작 상태
ship: PLACED -> SHIPPED # 명령: 시작 -> 도착
cancel: PLACED -> CANCELLED
delete: CANCELLED # 지울 수 있는 상태
states:아래의 키는 필드 이름(status)입니다. 타입 이름이 아닙니다.- 상태 이름은 enum 의 상수 이름(
PLACED)입니다. 코드값(PLC)이 아닙니다. initial과delete말고 나머지 키는 모두 명령입니다:ship:은ship()과POST /api/.../{id}/ship을 만듭니다. 파라미터가 필요할 때만commands:에 같은 이름으로 적습니다.- 시작 상태가 여럿이면 쉼표:
cancel: PLACED, SHIPPED -> CANCELLED. - 화살표가 나가지 않는 상태는 끝 상태입니다.
생성되는 것
| 생성물 | 내용 |
|---|---|
private OrderStatus status = OrderStatus.PLACED | 시작 상태. initial 이 없으면 새 행의 상태가 비어 어떤 명령도 통과하지 못함 |
PurchaseOrderBase.ship() | 지금 상태 확인 -> 이동 -> 도착 상태와 같은 이름의 @event 가 있으면 raiseShipped(). 시작 상태가 아니면 409 |
PurchaseOrderBase.canShip() | 지금 이 명령을 할 수 있는지 |
POST /{id}/ship, 유스케이스, 서비스 | commands: 와 같음 |
TypeScript canShip(dto) | 버튼을 보일지 정할 때 |
DTO 의 status | 읽기 전용 - 생성 요청에 "status": "SHP" 를 넣어도 무시. 상태는 명령으로만 바뀜 |
시작 상태가 아닌데 명령이 오면:
POST /api/petstore/purchaseOrder/{id}/cancel (주문이 SHIPPED)
HTTP/1.1 409
{ "title": "Not allowed in this state",
"detail": "PurchaseOrder cannot cancel when status is SHIPPED; it must be [PLACED]",
"code": "state.PurchaseOrder.status.cancel", "status": 409 }
Accept-Language: ko 와 한국어 번역이 있으면 제목과 문장이 한국어로 나갑니다 - 메시지와 언어.
생성된 Base 의 주석에 생명주기가 글로 남아 DSL 을 열지 않아도 읽을 수 있습니다:
/**
* Lifecycle of status (OrderStatus), from the DSL's states:
* start: PLACED
* ship: PLACED -> SHIPPED
* cancel: PLACED -> CANCELLED
* end: SHIPPED, CANCELLED - no command leaves them
*/
이동에 일을 더 하고 싶을 때
이동은 생성 코드가 하고, 더 할 일(배송 번호 기록 등)은 행위 클래스에서 override 합니다:
public class PurchaseOrder extends PurchaseOrderBase {
@Override
public void ship() {
// 이동 전에 확인할 것이 있으면 여기서
super.ship(); // 상태 확인 + 이동 + 이벤트
// 이동 후에 할 일
}
}
다른 애그리거트나 포트가 필요한 일(택배사 호출)은 확장 서비스에서 ship(orderId) 를 override 합니다.
지울 수 있는 상태: delete:
delete: CANCELLED (또는 [CANCELLED, LOST]) 는 그 상태일 때만 행을 지울 수 있게 합니다. 명령이 아니라 상태
목록이라 엔드포인트가 더 생기지 않습니다.
| 생성물 | 내용 |
|---|---|
canDelete() | 지금 지울 수 있는지 |
checkDeletable() | 다른 상태면 409 (state.PurchaseOrder.status.delete), 행은 그대로. JPA @PreRemove 라 모든 삭제 경로에 걸림 |
TypeScript canDelete(dto) | 삭제 버튼 |
DELETE /api/petstore/purchaseOrder/{배송된 주문}
HTTP/1.1 409
{ "code": "state.PurchaseOrder.status.delete",
"detail": "PurchaseOrder cannot be deleted when status is SHIPPED; it must be [CANCELLED]" }
delete: 가 없으면 삭제는 생명주기와 무관합니다. delete: [] 는 어떤 상태에서도 지울 수 없음입니다. 누가 지울 수
있는지는 @access(delete=admin) 이 정합니다. JPQL 일괄 삭제, 네이티브 SQL, ON DELETE CASCADE 는 JPA 콜백을 거치지
않습니다.
생명주기가 여럿일 때
enum 필드 둘에 각각 생명주기를 둘 수 있고, 같은 명령 이름이 둘에 있으 면 그 명령은 둘을 함께 바꿉니다:
Shipment:
meta: entity @root @expose(20) @event(Sent, Cancelled, Refunded)
fields:
shipmentId: string(36) @id @genid
status: ShipmentStatus
fee: FeeStatus
states:
status:
initial: READY
send: READY -> SENT
cancel: READY -> CANCELLED
fee:
initial: DUE
pay: DUE -> PAID
cancel: PAID -> REFUNDED # 같은 명령 cancel: 둘을 함께 바꿈
cancel() 은 모든 생명주기를 먼저 확인하고 모두 통과할 때만 이동합니다 - 아니면 409 이고 어느 필드도 바뀌지 않습니다.
canCancel() 은 조건의 AND 입니다.
파라미터가 있는 명령: commands:
SalesOrder:
meta: entity @root @expose(20)
fields:
orderId: string(36) @id @genid
status: OrderStatus @behavior
commands:
confirm: # 파라미터 없음
ship: # 파라미터는 필드처럼 쓴다
warehouse: string(20)!
| 생성물 | 성격 |
|---|---|
POST /api/erp/salesOrder/{orderId}/ship (camelCase 명령은 kebab-case 경로) | 새 컨트롤러에 생성 |
SalesOrderShipCommand - 파라미터가 있는 명령의 요청 DTO | 재생성 |
SalesOrderUseCase.ship(orderId, command) | 재생성 |
SalesOrderCoreService.ship(...) - 불러오기, entity.ship(warehouse), 저장, 트랜잭션 | 재생성 |
SalesOrderBase: abstract void ship(String warehouse) | 재생성 - 쓸 때까지 컴파일 오류 |
SalesOrder.ship(String) - 규칙 | 내 것 (처음엔 예외를 던지는 틀) |
파라미터는 값입니다. 다른 애그리거트는 식별자로 받습니다. states: 의 이동이기도 한 명령은 본문까지 생성됩니다.
빌드 경고
| 경고 | 결과 |
|---|---|
states.status names SHIPED, which OrderStatus does not have (it has PLACED, SHIPPED, CANCELLED) | 생명주기 생략. 명령은 추상 메서드로 남음 |
states names status, which PurchaseOrder has no field for | 생략 |
states names status, which is not an enum of the model | 생략 |
states.status.ship: '...' is not FROM -> TO | 그 이동만 생략 |
has @default and states.status.initial | initial 이 이김 |
경고는 [elcore3] DSL validation warnings: 아래에 모이고 빌드는 멈추지 않습니다. 생명주기가 빠지면 컴파일할 때
PurchaseOrder is not abstract and does not override ship() 으로 드러납니다.