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

Derived values, events and domain services

These need architecture = 'ddd' or 'hexagonal'.

@snapshot and @compute​

OrderLine:
fields:
item: Item @ManyToOne
quantity: quantity! @min(1)
unitCost: money(10.2) @snapshot(item.unitCost) # the price when ordered, copied once
subTotal: money(12.2) @compute(unitCost * quantity)
PurchaseOrder:
fields:
totalWithoutVat: money(12.2) @compute(sum(lines.subTotal))
  • @snapshot(item.unitCost) copies the referenced row's value once, when it is empty - a later price change leaves the order as it was. A referenced row that does not exist answers 404 (Item 99 does not exist, code reference.OrderLine.item).
  • @compute(...) is worked out on every save: a * b, a + b, a - b (fields or numbers) and sum(members.field). If an operand is null the result is null.
  • Neither is taken from a request (read-only in the DTO, not written by the mapper - also on @editable entities).
  • The entity gets derive(lookup); the core service calls it before saving on create, update, batch and conditional update, and after a command. Members are derived first, so a sum adds the newest lines. A @compute that uses another @compute declares that one first.
  • Not @formula: @formula("...") is SQL the database computes.

What the pet store's order line got:

OrderLineBase.java (generated)
public void derive(io.elasticore.springboot3.domain.Lookup lookup) {
if (this.unitCost == null && this.itemId != null) {
Item source = (Item) lookup.find(Item.class, this.itemId);
if (source == null)
throw new io.elasticore.springboot3.validation.InvariantViolationException("reference.OrderLine.item", "Item " + this.itemId + " does not exist", String.valueOf(this.itemId));
this.unitCost = source.getUnitCost();
}
this.subTotal = (this.unitCost == null || this.quantity == null) ? null : this.unitCost.times(this.quantity);
}

@event - domain events​

PurchaseOrder:
meta: entity @root @expose(20) @event(Placed(total), Shipped, Cancelled)
GeneratedWhat
PurchaseOrderPlacedEvent, ...ShippedEvent, ...CancelledEventimmutable values with the root's id, the declared fields and occurredOn (Java: equals/hashCode/toString; Kotlin: data class)
raisePlaced(), raiseShipped(), ...raising methods on the root
@DomainEvents / @AfterDomainEventPublicationSpring Data publishes them when the root is saved
  • Published when saved; a failed save publishes nothing; published events are cleared, so the next save does not repeat them.
  • The payload in brackets (Placed(total)) is the value at the time raisePlaced() is called: columns, enums, value objects. References to other aggregates and collections are left out with a warning - carry the id column instead.
  • When an event happens is not generated - except a states: step, which raises the event named after its target state (SHIPPED raises Shipped). Otherwise call raiseCancelled() where your code decides it is a cancellation.
  • Events go to namespace.event (otherwise the entity's package). Receive them with an @EventListener of your own. The agent kit's shop recipe declares @event(Shipped(total), Cancelled(total)), so its events carry total:
OrderEvents.java (yours, from the agent kit's shop recipe)
@Component
class OrderEvents {
private static final Logger log = LoggerFactory.getLogger(OrderEvents.class);

@EventListener
void shipped(PurchaseOrderShippedEvent e) {
log.info("order {} SHIPPED total={}", e.getOrderId(), e.getTotal());
}
}

The outbox: events out of the JVM​

Spring Data's events stay inside the JVM. With outbox: true in env.yml every event is written to an outbox table in the same transaction and relayed after commit:

File (infrastructure package)Role
<Domain>OutboxMessagethe entity (<prefix>outbox table)
<Domain>OutboxRepositoryreads the next 100 unpublished, locked, in order
<Domain>OutboxRecorderwrites every event as JSON BEFORE_COMMIT
<Domain>OutboxRelayrelay(): sends to your OutboxPublisher; on failure that aggregate's later messages wait (order kept); elasticore.outbox.max-attempts (10); scheduled every elasticore.outbox.relay-delay-ms (5000) with @EnableScheduling
@Bean
OutboxPublisher kafka(KafkaTemplate<String, String> kafka) {
return m -> kafka.send("erp-events", m.getAggregateId(), m.getPayload()).get();
}

Delivery is at least once - consumers drop duplicates by OutboxEnvelope.getId().

domainService: - an operation of no single aggregate​

model/domainservice.yml
domainService:
Pricing:
desc: What an order costs once the products it names are priced
uses: [Order, Product]

Two files: PricingDomainServiceBase (regenerated - the uses as injected repository ports) and PricingDomainService (generate-once, @Service, the operation is yours). Adding to uses adds a port to the base; your class does not change. An empty uses, or one with a single aggregate, is a warning - an operation on one aggregate is usually its method. Keep namespace.domainService apart from namespace.service.

processManager: - the next step after an event​

processManager:
Billing:
desc: one invoice per shipped sales order
on: [SalesOrderShipped]
uses: [Invoice]
generated (excerpt)
// BillingProcessManagerBase - regenerated
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(SalesOrderShippedEvent event) {
handle(event);
}
protected abstract void handle(SalesOrderShippedEvent event);

// BillingProcessManager - generate-once, yours
@Component
public class BillingProcessManager extends BillingProcessManagerBase {
@Override
protected void handle(SalesOrderShippedEvent event) {
// The next step goes here.
}
}

It runs after the event's transaction commits, in a transaction of its own; a failure here does not undo what already happened, so retries and compensation are handle's. Write it idempotently. A new event in on adds an abstract handle - the compiler names what is missing.

Events across bounded contexts​

Another context's event is written context:RootEvent:

blueprint/billing/model/domainservice.yml
processManager:
Receipting:
desc: one receipt per order placed
on: [shop:OrderPlaced]
uses: [Receipt]

The boundaries are checked like types (see env.yml - bounded contexts): the receiving context lists shop in uses, and a shop that declares publishes lists OrderPlaced. Delivery is a Spring application event, so it works inside one application (a modular monolith); for contexts deployed apart the sending side uses the outbox, and the receiving side subscribes to the broker itself - that part is not generated. Example: billing in elasticore-template-ddd (CrossContextEventTest: a committed order reaches billing, a rolled-back one does not).