SaveHandler#
com.palmyralabs.palmyra.handlers.SaveHandler
Upsert semantics — insert when the key is absent, update when present. Composes PreProcessor.
Each hook receives the incoming Tuple, the pre-image dbTuple (null on insert), the HandlerContext, and a MutableAction indicating whether the framework will insert or update.
Request / response — User#
Assuming the User model from QueryHandler → Worked example.
Save lookups honour @PalmyraType.preferredKey — in the example model that’s loginName, so clients can upsert by natural key without supplying id.
Request — insert path#
No id in the payload; loginName doesn’t match an existing row — Palmyra inserts.
POST /api/v1/admin/user
Content-Type: application/json{
"loginName": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"status": "ACTIVE",
"tenantId": 3
}Response — the newly-inserted row:
{
"id": 102,
"loginName": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"status": "ACTIVE",
"tenantId": 3,
"createdAt": "2026-04-01T09:12:00Z",
"createdBy": "admin@example.com"
}Request — update path#
Same endpoint, same shape — but loginName matches an existing row, so Palmyra updates instead of inserting:
POST /api/v1/admin/user
Content-Type: application/json{
"loginName": "ada@example.com",
"lastName": "Lovelace-Byron",
"status": "ACTIVE"
}Response — the updated row:
{
"id": 102,
"loginName": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace-Byron",
"status": "ACTIVE",
"tenantId": 3,
"createdAt": "2026-04-01T09:12:00Z",
"createdBy": "admin@example.com"
}Which branch executed is visible to the handler through the MutableAction argument on each lifecycle hook (MutableAction.INSERT / MutableAction.UPDATE).
Methods#
| Method | Signature |
|---|---|
validate |
void validate(Tuple tuple, HandlerContext ctx) |
preProcessRaw |
Tuple preProcessRaw(Tuple tuple, HandlerContext ctx) |
preProcess |
Tuple preProcess(Tuple tuple, HandlerContext ctx) |
aclCheck |
int aclCheck(Tuple tuple, HandlerContext ctx) |
getAcl |
int getAcl(Tuple tuple, HandlerContext ctx) |
preSave |
Tuple preSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) |
onSave |
Tuple onSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) |
postSave |
Tuple postSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) |
rollback |
Tuple rollback(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) |
Example#
@Component
@CrudMapping(value = "/v1/admin/user", type = User.class)
public class UserSaveHandler implements SaveHandler {
@Autowired
private UserProvider userProvider;
@Autowired
private OutboxService outbox;
@Override
public Tuple preSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) {
Instant now = Instant.now();
String user = userProvider.getUserId();
if (action == MutableAction.INSERT) {
tuple.set("createdBy", user);
tuple.set("createdAt", now);
} else {
tuple.set("updatedBy", user);
tuple.set("updatedAt", now);
}
return tuple;
}
@Override
public Tuple onSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) {
// hash password only when it's actually changing
String raw = (String) tuple.get("password");
if (raw != null && !raw.isBlank()) {
tuple.set("passwordHash", BCrypt.hash(raw));
}
tuple.remove("password");
return tuple;
}
@Override
public Tuple postSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) {
String event = action == MutableAction.INSERT ? "user.created" : "user.updated";
outbox.publish(event, tuple);
return tuple;
}
@Override
public Tuple rollback(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) {
outbox.discardPending(tuple.get("id"));
return tuple;
}
}