Cross-entity business validation#
When a handler needs to enforce rules that span multiple tables — “the invoice total must not exceed the contract award” or “the assigned contractor must belong to this project” — @PalmyraField annotations aren’t enough. The validation lives in the handler’s lifecycle hooks, backed by JPA repositories (or any injected service).
The pattern#
Override preCreate / preUpdate / preDelete and call your repositories from there. The tuple carries the incoming payload; the repositories carry the truth. Throw a domain exception to abort the operation.
@Component
@CrudMapping(mapping = "/invoice", type = InvoiceModel.class,
secondaryMapping = "/invoice/{id}")
public class InvoiceHandler extends AbstractHandler
implements CreateHandler, UpdateHandler, DeleteHandler {
@Autowired private ContractRepo contractRepo;
@Autowired private InvoiceLineRepo lineRepo;
@Autowired private ContractorRepo contractorRepo;
@Autowired private InvoiceNumberGen numberGen;
// --- Create --------------------------------------------------------
@Override
public Tuple preCreate(Tuple tuple, HandlerContext ctx) {
// 1. Auto-generate the invoice number
Long projectId = tuple.getAttributeAsLong("projectId");
tuple.setAttribute("invoiceNumber", numberGen.next(projectId));
// 2. Ensure the contractor is allocated to this project
Long contractorId = tuple.getAttributeAsLong("contractorId");
if (!contractorRepo.isAllocatedToProject(contractorId, projectId)) {
throw new BusinessException("Contractor is not allocated to this project");
}
// 3. Check the contract ceiling
validateAmountCeiling(tuple, projectId);
tuple.setAttribute("status", "DRAFT");
tuple.setAttribute("createdBy", currentUser());
return tuple;
}
// --- Update --------------------------------------------------------
@Override
public Tuple preUpdate(Tuple tuple, Tuple dbTuple, HandlerContext ctx) {
// Only DRAFT / SUBMITTED / REJECTED invoices can be edited
String status = (String) dbTuple.get("status");
if (!Set.of("DRAFT", "SUBMITTED", "REJECTED").contains(status)) {
throw new BusinessException("Cannot edit an invoice in status: " + status);
}
Long projectId = dbTuple.getAttributeAsLong("projectId");
validateAmountCeiling(tuple, projectId);
tuple.setAttribute("updatedBy", currentUser());
return tuple;
}
// --- Delete --------------------------------------------------------
@Override
public Tuple preDelete(Tuple tuple, HandlerContext ctx) {
String status = (String) tuple.get("status");
if (!"DRAFT".equals(status)) {
throw new BusinessException("Only DRAFT invoices can be deleted");
}
return tuple;
}
// --- Shared rule ---------------------------------------------------
private void validateAmountCeiling(Tuple tuple, Long projectId) {
BigDecimal invoiceAmount = (BigDecimal) tuple.get("totalAmount");
if (invoiceAmount == null) return;
BigDecimal awardedValue = contractRepo.getAwardedValue(projectId);
BigDecimal deviation = contractRepo.getAllowedDeviation(projectId);
BigDecimal ceiling = awardedValue.add(deviation);
BigDecimal alreadyBilled = lineRepo.sumApprovedAmounts(projectId);
if (alreadyBilled.add(invoiceAmount).compareTo(ceiling) > 0) {
throw new BusinessException(
"Invoice total (%s) would exceed the contract ceiling (%s)"
.formatted(alreadyBilled.add(invoiceAmount), ceiling));
}
}
}Guidelines#
- Keep hooks fast. Repository calls should hit indexed columns. Avoid N+1 patterns — batch lookups into a single query if you validate more than a handful of line items.
- Throw domain exceptions. A
BusinessException(or your equivalent) returns400 Bad Requestwith a meaningful message. Don’t return boolean flags — the framework won’t know the operation failed. - Use
dbTuplefor “before” state. On update,dbTupleis the pre-image. Compare it with the incomingtupleto detect what actually changed before running expensive cross-entity checks. - Status-gating edits. The
preUpdate/preDeletepattern of checking the stored status is the standard way to enforce workflow transitions — only certain statuses allow editing.
When to use this vs. @PalmyraField validation#
| Scenario | Approach |
|---|---|
| Field is required on create | @PalmyraField(mandatory = Mandatory.CREATE) |
| Field matches a regex | @PalmyraField(pattern = "...") |
| Value must be unique | @PalmyraUniqueKey or DB constraint |
| Value depends on another table’s data | Handler lifecycle hook + repository lookup (this page) |
| Status-based edit restrictions | Handler preUpdate / preDelete (this page) |
See also: CreateHandler, UpdateHandler, PreProcessor.