Automatic JPA audit fields with EntityListeners#

When every table needs created_by, created_on, last_upd_by, last_upd_on — and you want them populated from Spring Security without repeating the logic per entity — use a JPA @EntityListeners approach alongside your Palmyra handlers.

Reusable audit base#

// Embeddable timestamps
@Embeddable
@Getter @Setter
public class Timestamps {
    @Column(name = "created_on", updatable = false) private Instant createdOn;
    @Column(name = "created_by", updatable = false, length = 128) private String createdBy;
    @Column(name = "last_upd_on") private Instant lastUpdOn;
    @Column(name = "last_upd_by", length = 128) private String lastUpdBy;
}

// Marker interface
public interface Auditable {
    Timestamps getTimestamps();
    void setTimestamps(Timestamps ts);
}

The listener#

public class AuditListener {

    @PrePersist
    public void prePersist(Object entity) {
        if (entity instanceof Auditable a) {
            Timestamps ts = a.getTimestamps();
            if (ts == null) { ts = new Timestamps(); a.setTimestamps(ts); }
            String user = currentUser();
            Instant now = Instant.now();
            ts.setCreatedOn(now);
            ts.setCreatedBy(user);
            ts.setLastUpdOn(now);
            ts.setLastUpdBy(user);
        }
    }

    @PreUpdate
    public void preUpdate(Object entity) {
        if (entity instanceof Auditable a) {
            Timestamps ts = a.getTimestamps();
            if (ts == null) { ts = new Timestamps(); a.setTimestamps(ts); }
            ts.setLastUpdOn(Instant.now());
            ts.setLastUpdBy(currentUser());
        }
    }

    private String currentUser() {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        return (auth != null && auth.isAuthenticated()) ? auth.getName() : "system";
    }
}

Applying to an entity#

@Entity
@Table(name = "project")
@EntityListeners(AuditListener.class)
@Getter @Setter
public class ProjectEntity implements Auditable {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false) private String name;
    @Column(nullable = false) private String status;

    @Embedded
    private Timestamps timestamps = new Timestamps();
}

Every entityManager.persist(project) and entityManager.merge(project) now stamps the four audit columns — no handler code needed for JPA-managed saves.

How this relates to Palmyra’s DropMode approach#

Approach When to use
JPA @EntityListeners (this page) Entities saved through JPA (entityManager / Spring Data repositories) — e.g. in custom controllers, services, scheduled jobs
Handler preSave / preCreate + DropMode.INCOMING Records saved through Palmyra handlers — the Tuple carries the data, not a JPA entity

In a mixed codebase (JPA entities for custom logic, Palmyra models for CRUD handlers), use both: the listener catches JPA saves; the handler hook catches Palmyra saves. The audit columns get stamped either way.

Guidelines#

  • Make Timestamps an @Embeddable rather than repeating four @Column annotations per entity.
  • Fall back to "system" when no auth context exists (scheduled jobs, data-migration scripts).
  • Don’t fight Palmyra for handler-managed saves. Palmyra handlers operate on Tuple, not JPA entities — the listener won’t fire for those. Use the preSave hook on the handler side.

See also: Cross-entity validation (handler hooks that stamp fields on Palmyra-managed saves).