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) returns 400 Bad Request with a meaningful message. Don’t return boolean flags — the framework won’t know the operation failed.
  • Use dbTuple for “before” state. On update, dbTuple is the pre-image. Compare it with the incoming tuple to detect what actually changed before running expensive cross-entity checks.
  • Status-gating edits. The preUpdate / preDelete pattern 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.