Lifecycle (states:) and commands (commands:)
Rules about which state a record may move to from which are written in the DSL, not in code. The
command methods are generated with their bodies. These need architecture = 'ddd' or 'hexagonal'.
Three places, connected
enumeration:
OrderStatus: # (1) the states = the enum's constant names
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) the field that holds the state
states:
status: # (3) the key is the field's NAME
initial: PLACED # where a new order starts
ship: PLACED -> SHIPPED # command: from -> to
cancel: PLACED -> CANCELLED
delete: CANCELLED # the states a row may be deleted in
- The key under
states:is the field name (status), not the type name. - State names are the enum's constant names (
PLACED), not the codes (PLC). - Every key other than
initialanddeleteis a command:ship:givesship()andPOST /api/.../{id}/ship. Commands need nocommands:entry unless they take parameters. - Several start states:
cancel: PLACED, SHIPPED -> CANCELLED. - A state no arrow leaves is a final state.
What is generated
| Generated | What |
|---|---|
private OrderStatus status = OrderStatus.PLACED | the start state; without initial a new row has none and no command passes |
PurchaseOrderBase.ship() | checks the state, moves it, raises Shipped when @event has an event of the target's name; otherwise 409 |
PurchaseOrderBase.canShip() | whether the command may be given now |
POST /{id}/ship, the use case, the service | as for commands: |
TypeScript canShip(dto) | whether to show the button |
the DTO's status | read-only - "status": "SHP" in a create is ignored; the state changes by commands only |
A command in a state it does not start in:
POST /api/petstore/purchaseOrder/{id}/cancel (the order is 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 }
With Accept-Language: ko and a Korean translation, the title and detail come in Korean - see
Messages and languages.
The generated base documents the lifecycle so it can be read without the 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
*/
Doing more in a step
The move is generated; more work (a tracking number) goes in your behaviour class:
public class PurchaseOrder extends PurchaseOrderBase {
@Override
public void ship() {
// checks before the move
super.ship(); // state check + move + event
// work after the move
}
}
Work that needs other aggregates or ports (a courier call) goes in the extension service, overriding
ship(orderId).
Deletable states: delete:
delete: CANCELLED (or [CANCELLED, LOST]) allows deleting a row only in those states. It is a
list of states, not a command - no endpoint is added.
| Generated | What |
|---|---|
canDelete() | whether it may be deleted now |
checkDeletable() | other states: 409 (state.PurchaseOrder.status.delete), the row stays - a JPA @PreRemove, so every delete path is covered |
TypeScript canDelete(dto) | the delete button |
DELETE /api/petstore/purchaseOrder/{a shipped order}
HTTP/1.1 409
{ "code": "state.PurchaseOrder.status.delete",
"detail": "PurchaseOrder cannot be deleted when status is SHIPPED; it must be [CANCELLED]" }
Without delete: deleting is unrelated to the lifecycle; delete: [] means never. Who may delete
is @access(delete=admin). JPQL bulk deletes, native SQL and ON DELETE CASCADE do not pass through
JPA callbacks.
Several lifecycles
Two enum fields can have a lifecycle each; a command named in both moves both:
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 # the same command: both move
cancel() checks every lifecycle first and moves only when all pass - otherwise 409 and no field
changes. canCancel() is the AND of the conditions.
Commands with parameters: commands:
SalesOrder:
meta: entity @root @expose(20)
fields:
orderId: string(36) @id @genid
status: OrderStatus @behavior
commands:
confirm: # no parameters
ship: # parameters are written like fields
warehouse: string(20)!
| Generated | Kind |
|---|---|
POST /api/erp/salesOrder/{orderId}/ship (camelCase commands become kebab-case paths) | in a new controller |
SalesOrderShipCommand - the request DTO of a command with parameters | regenerated |
SalesOrderUseCase.ship(orderId, command) | regenerated |
SalesOrderCoreService.ship(...) - load, entity.ship(warehouse), save, in a transaction | regenerated |
SalesOrderBase: abstract void ship(String warehouse) | regenerated - a compile error until you write it |
SalesOrder.ship(String) - the rule | yours (first generated as a stub that throws) |
Parameters are values; another aggregate is passed by id. A command that is also a states: step gets
its body generated.
Build warnings
| Warning | Result |
|---|---|
states.status names SHIPED, which OrderStatus does not have (it has PLACED, SHIPPED, CANCELLED) | the lifecycle is left out; its commands stay abstract |
states names status, which PurchaseOrder has no field for | left out |
states names status, which is not an enum of the model | left out |
states.status.ship: '...' is not FROM -> TO | that step left out |
has @default and states.status.initial | initial wins |
Warnings are listed under [elcore3] DSL validation warnings: and do not stop the build. A lifecycle
left out shows up as PurchaseOrder is not abstract and does not override ship() when compiling.