The Palmyra mental model#

One paragraph, then the details.

The database schema is the source of truth. Annotations declare overrides and intent on top of what Palmyra has already read from JDBC metadata. Handlers are the only place you write behaviour. Get this order right and the framework is a very small surface; get it wrong and error messages can feel cryptic because they assume you already know.

Read this before wiring a new entity if Palmyra is new to you — it’s the “thing I wish I knew on day 1” page. Every point below is something the framework expects but doesn’t always announce.

1. The database is first, POJOs are second#

Palmyra reads the running database’s metadata (tables, columns, primary keys, unique keys, foreign keys) via JDBC at startup. This is why:

  • You don’t need JPA entities for Palmyra to work. Model POJOs annotated with @PalmyraType carry routing and presentation concerns; the shape of the data comes from the DB.
  • Adding an @PalmyraField to a property that doesn’t match a DB column is a silent no-op at runtime — the mapping layer has nothing to bind.
  • Removing a column from the DB while it still exists on the model doesn’t break the model; the field just never gets populated.

Implication for new development: write the migration / DDL first, restart Palmyra, then annotate the model. It’s the fastest feedback loop.

2. Foreign keys follow the DB, not the annotations#

When a model has a field that references another table (private PatientModel patient), Palmyra resolves the join by reading the FK from the database schema. No annotation needed.

You reach for @PalmyraMappingConfig (with @PalmyraForeignKey / @PalmyraUniqueKey inside it) only when:

  • The schema is shared / read-only and you can’t add a physical FK.
  • A legacy table models relationships in application code only.
  • You’re working against a view or a staging table with constraints omitted for flexibility.

For a normal, well-constrained schema you write zero foreign-key annotations.

The single most common first-day error#

RuntimeException: <field> subtype not found in <Type>

This is Palmyra saying: “I looked for a FK on <field> via JDBC metadata and didn’t find one.” Almost always, the fix is to add the FK constraint to the DB (or declare it in @PalmyraMappingConfig if the schema can’t take it).

3. Handlers are the only place for behaviour#

A @Component @CrudMapping(...) class that implements some mix of QueryHandler / ReadHandler / CreateHandler / UpdateHandler / DeleteHandler / PreProcessor is a complete endpoint. The framework:

  1. Derives the SQL from client query params + DB metadata.
  2. Applies @Permission checks.
  3. Invokes any hooks you override (applyQueryFilter, preCreate, onUpdate, onQueryResult, …).
  4. Serialises the tuple.

Keep business rules in the handler hooks — they’re the surface the framework gives you. Custom JDBC / JPQL / raw Spring @RestController code tends to be a sign that either a hook was missed or an @PalmyraField(parentRef=...) / NativeQueryHandler would fit better.

4. Annotations that are NOT needed in the common case#

You don’t need When You might reach for it
@PalmyraMappingConfig The DB has your PK/UK/FK constraints Read-only or legacy schemas
@PalmyraForeignKey The DB has the FK App-managed FK, or cross-schema soft references
@PalmyraUniqueKey The DB has the UK App-enforced uniqueness only
JPA entity for the table You don’t need Spring Data JPA for ancillary queries You want JpaRepository.findBy… alongside Palmyra
Custom @RestController A handler hook (preCreate, applyQueryFilter, …) fits Truly non-CRUD actions — use @ActionMapping first

5. The DropMode gotcha#

@PalmyraField(drop = DropMode.XYZ) accepts NONE, INCOMING, OUTGOING, or NULL. There is no BOTH.

  • INCOMING — drops the field on write paths (create/update) but still reads it.
  • OUTGOING — drops on read/serialise but still accepts on write.
  • NULL — treats null values as absent.
  • NONE — no dropping (default).

To exclude a field in both directions (e.g., a password hash column), omit it from the model. Palmyra won’t touch what it doesn’t know about.

6. Error messages assume framework familiarity#

A curated list of “what it looks like” → “what it means”:

Error What Palmyra is telling you
<field> subtype not found in <Type> Missing FK in DB (or missing @PalmyraForeignKey for app-managed schemas). See §2.
field limit not found for filter criteria Client sent limit=N as a filter; ignore — pagination params are intentional.
Mandatory attribute … must be provided on update Palmyra’s update is a full-body replace (not PATCH). Send the complete object.
formatFooter is not a function (frontend grid) Hand-rolled GridCustomizer missing one of the three formatters. Use useGridColumnCustomizer(config) which fills in all three.
Cannot compare left expression of type '<Entity>' with right expression of type 'Long' JPA-side issue (not Palmyra) — @ManyToOne on the entity made a field’s type an entity, so Spring Data’s findByFoo(Long) no longer matches. Rename to findByFooId(Long).

7. A minimal checklist when adding a new entity#

  1. Write the migration: table + PK + UK + FK constraints.
  2. Restart the service (Palmyra reads metadata at bootstrap).
  3. Create a POJO with @PalmyraType(type="X", table="x_table", preferredKey="code") and @PalmyraField per column you want exposed.
  4. Create a handler class: @Component, @CrudMapping(mapping="/x", type=X.class, secondaryMapping="/x/{id}"), @Permission(value="X", create="CUD", update="CUD", delete="CUD", query="RS", read="RS"), extends AbstractAclHandler implements QueryHandler, ReadHandler, CreateHandler, UpdateHandler, DeleteHandler.
  5. Point a React SummaryGrid at /x with column definitions matching the model attributes. FK fields show up as nested objects — use useGridColumnCustomizer({fk: enhancer}) to dot-walk to the display attribute.

Everything else — search, sort, pagination, export, ACL enforcement, nested FK resolution — comes for free because of steps 1 and 3.

See also#