Custom controllers with Palmyra ACL#
Palmyra handlers cover the CRUD surface. Non-CRUD actions — multi-record payments, approve/reject workflows, custom aggregation endpoints — belong in plain Spring @RestControllers. The two patterns coexist: handlers own the data, controllers own the actions.
The boundary#
| Use a handler when… | Use a custom controller when… |
|---|---|
| The operation maps to a single entity’s CRUD lifecycle | The operation spans multiple entities in one call |
| Pagination, sort, search, export are needed | The request shape doesn’t fit the standard query-param contract |
@CrudMapping + handler interfaces cover the surface |
The endpoint needs a custom URL, custom request body, or a non-JSON response |
Securing custom controllers with Palmyra’s ACL#
@PreAuthorize("hasPermission(...)") flows through the same PalmyraPermissionEvaluator that resolves @Permission on handlers. The ACL tables are the single source of truth for both surfaces.
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
@PostMapping
@PreAuthorize("hasPermission(#request, 'PAYMENT_CREATE')")
public ResponseEntity<?> createPayment(@RequestBody PaymentRequest request) {
// request.invoiceIds may contain 5–20 invoices paid in one transaction
var result = paymentService.processMultiInvoicePayment(request);
return ResponseEntity.ok(result);
}
@PostMapping("/{id}/approve")
@PreAuthorize("hasPermission(#id, 'Payment', 'PAYMENT_APPROVE')")
public ResponseEntity<?> approve(@PathVariable Long id) {
paymentService.approve(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/reject")
@PreAuthorize("hasPermission(#id, 'Payment', 'PAYMENT_REJECT')")
public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody RejectReason reason) {
paymentService.reject(id, reason);
return ResponseEntity.noContent().build();
}
}The request DTO#
Custom endpoints carry domain-shaped DTOs, not Palmyra tuples:
@Data
public class PaymentRequest {
private Long vendorId;
private LocalDate paymentDate;
private String paymentMode; // CHEQUE, NEFT, RTGS
private String referenceNumber;
private List<Long> invoiceIds; // bulk: pay multiple invoices at once
private BigDecimal totalAmount;
}Coexistence with handlers#
The same entity can have both. The handler serves the standard CRUD surface; the controller serves the actions.
// Standard CRUD — paginated list, single-record read, upsert
@Component
@CrudMapping(mapping = "/payment", type = PaymentModel.class,
secondaryMapping = "/payment/{id}")
public class PaymentHandler extends AbstractHandler
implements QueryHandler, ReadHandler, SaveHandler { }
// Actions — multi-invoice creation, approve, reject
@RestController
@RequestMapping("/api/payment")
public class PaymentController { /* ... */ }Both resolve permissions through the same ACL tables — @Permission on the handler and @PreAuthorize("hasPermission(...)") on the controller call the same PermissionEvaluator.
See also: PalmyraPermissionEvaluator, @Permission.