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
Timestampsan@Embeddablerather than repeating four@Columnannotations 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 thepreSavehook on the handler side.
See also: Cross-entity validation (handler hooks that stamp fields on Palmyra-managed saves).