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, codereference.OrderLine.item).@compute(...)is worked out on every save:a * b,a + b,a - b(fields or numbers) andsum(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
@editableentities). - 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@computethat uses another@computedeclares that one first. - Not
@formula:@formula("...")is SQL the database computes.
What the pet store's order line got:
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)
| Generated | What |
|---|---|
PurchaseOrderPlacedEvent, ...ShippedEvent, ...CancelledEvent | immutable 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 / @AfterDomainEventPublication | Spring 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 timeraisePlaced()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 (SHIPPEDraisesShipped). Otherwise callraiseCancelled()where your code decides it is a cancellation. - Events go to
namespace.event(otherwise the entity's package). Receive them with an@EventListenerof your own. The agent kit's shop recipe declares@event(Shipped(total), Cancelled(total)), so its events carrytotal:
@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>OutboxMessage | the entity (<prefix>outbox table) |
<Domain>OutboxRepository | reads the next 100 unpublished, locked, in order |
<Domain>OutboxRecorder | writes every event as JSON BEFORE_COMMIT |
<Domain>OutboxRelay | relay(): 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
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]
// 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:
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).