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;
    }
}