Build1 publisher3 min readPublished
Three kinds of write land the same 'system' value in Spring Data JPA's created_by
Four annotations and one @EnableJpaAuditing fill created_by in five minutes. The hook underneath is a no-argument method that can only see the thread doing the write, and a dev.to walkthrough starts where that leaves a scheduler.
The Engineer · Build desk
What happened
- Spring Data JPA fills created_by, created_at, updated_by and updated_at from four annotations plus one @EnableJpaAuditing, which the dev.to post puts at five minutes of work.
- A month in, the post reports rows reading 'system' that cannot be told apart, leaving the trail unable to answer the one question it was added for.
- The whole configuration is a bare @Configuration class with @EnableJpaAuditing, and the auditorAwareRef attribute many tutorials add alongside an explicit @Bean is optional.
- A companion repository, auditor-aware-demo, is one Spring Boot module on in-memory H2 with two test classes, and mvn test is the whole workflow.
- The pins are Spring Boot 3.5.16, Spring Data JPA 3.5.13, Hibernate 6.6.53.Final, H2 2.3.232 and Java 21.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint getCurrentAuditor() is handed nothing, so identity has to already be on the thread doing the write; anything a queue consumer or an @Async task knew about the caller must be carried there on purpose.
- decision The fallback string is a schema decision made once. A single 'system' for every off-request write means no later query on created_by can separate a job from a user whose id was dropped in transit.
- cost Getters only plus updatable = false means any correction to created_by is a SQL migration, not entity code; the team that inherits a bad import pays in scripts.
- exposure A shared library pinned to Boot 3.5.x hands an out-of-support runtime to every service that adopts it, and the post's claim that 4.x needs no code change is what keeps that cheap to fix.
Spring Data offers one hook for every write, and it is a single method with no parameters: `Optional<T> getCurrentAuditor()` [4]. Nothing about the row being saved is passed in. Whatever the method returns has to come from state the thread performing the write can already reach [19]. In the author's services that state is a session-scoped bean holding the logged-in user, and the auditor reads from there instead of from Spring Security [5]. On a nightly archive thread the bean is not in scope. The post says the storage choice is beside the point: "the problem is not where the identity is stored. The problem is that on a scheduler thread there is no identity to read" [6].
The writes that land on the fallback are not one population. A nightly job that archives stale records never had a user [7]. A queue consumer that picks a command off a queue and executes it does have one, a real person whose identity got lost on the way [3]. An `@Async` method started inside a request finishes after the request is gone, so the identity existed and then went out of scope [7]. The text available stops mid-sentence in the note on `auditorAwareRef`, before the auditor component that decides what each of those cases returns [20].
The entity side is the part I would copy unchanged. `AuditableEntity` is a `@MappedSuperclass` with `AuditingEntityListener` attached and getters only, so the four fields are written by the listener and by nothing else [8]. Both created_* columns are declared `updatable = false`. A load, modify and merge cycle therefore leaves them out of the UPDATE [9]. The post argues that if application code can call setCreatedBy, sooner or later it will, usually in a migration or an import that somebody wrote in a hurry [10]. That closes one route to a wrong value, and it also means a service that has to correct created_by across a million rows does it in SQL, not through the entity.
The equals/hashCode note is easy to miss and expensive to relearn. Do not generate them over these fields: all four change the moment the entity is persisted, so an entity put in a `HashSet` before `save()` is a different object afterwards, and with Lombok that means `@Getter` on the superclass and not `@Data` [11]. The timestamps are `Instant` and not `LocalDateTime` because the trail gets read across services that may not share a timezone, and Spring Data converts `Instant` without configuration [12].
For the pattern to transfer, the shape has to match the one the post assumes: several Boot services, each with its own database, all persisting through JPA [17]. If identity lives in `SecurityContextHolder` instead of a session-scoped bean, none of the entity code changes and the gap on the off-request thread is exactly the same [6].
What to watch
- The auditor implementation itself: what getCurrentAuditor() returns when there is no user decides whether job writes, lost identities and pre-session writes stay distinguishable.
- A run of auditor-aware-demo on Boot 4.x, since the post asserts the code is unchanged while the pins sit on 3.5.x.
- Whether the shared library carries the caller id onto the queue message, the one case where a real user exists to recover.