Skip to main content
ElastiCORE 3.0 is a release candidate: 3.0.261022-rc1 on Maven Central. Projects on the 2.2 line: see the 2.2 documentation.
Version: 3.0

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 initial and delete is a command: ship: gives ship() and POST /api/.../{id}/ship. Commands need no commands: entry unless they take parameters.
  • Several start states: cancel: PLACED, SHIPPED -> CANCELLED.
  • A state no arrow leaves is a final state.

What is generated​

GeneratedWhat
private OrderStatus status = OrderStatus.PLACEDthe 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 serviceas for commands:
TypeScript canShip(dto)whether to show the button
the DTO's statusread-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:

PurchaseOrder.java (yours)
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.

GeneratedWhat
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)!
GeneratedKind
POST /api/erp/salesOrder/{orderId}/ship (camelCase commands become kebab-case paths)in a new controller
SalesOrderShipCommand - the request DTO of a command with parametersregenerated
SalesOrderUseCase.ship(orderId, command)regenerated
SalesOrderCoreService.ship(...) - load, entity.ship(warehouse), save, in a transactionregenerated
SalesOrderBase: abstract void ship(String warehouse)regenerated - a compile error until you write it
SalesOrder.ship(String) - the ruleyours (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​

WarningResult
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 forleft out
states names status, which is not an enum of the modelleft out
states.status.ship: '...' is not FROM -> TOthat step left out
has @default and states.status.initialinitial 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.