A production-style demo showing how to make audit columns work consistently with:
- managed entity updates
Panache.update(...)bulk updates- native SQL updates
- any other client that writes to the same PostgreSQL tables
This repository exists because @PreUpdate, @EntityListeners, and similar JPA/Hibernate mechanisms do not fire when data is changed through a bulk Query update. In other words: the classic ORM-based audit pattern breaks the moment a team starts using mutation queries for performance or convenience.
This demo uses a safer approach:
- The application writes the current user into a PostgreSQL transaction-local setting
- A database trigger reads that value
- The database owns
created_*/updated_*fields
That makes auditing transparent for developers and consistent across write paths.
With Quarkus + Panache, this works for managed entities:
product.name = "new-name";But this does not execute entity lifecycle callbacks:
productRepository.update("name = ?1 where id = ?2", "new-name", id);So if your auditing depends on @PreUpdate, @EntityListeners, or @UpdateTimestamp, you get inconsistent behavior:
- managed entity path → audit fields updated
- bulk update path → audit fields missing or stale
That inconsistency is exactly what this demo solves.
Put audit truth in PostgreSQL, not in ORM callbacks.
The Java side only needs to set a transaction-local variable:
select set_config('app.user', ?, true)Then the trigger can do the rest:
created_atcreated_byupdated_atupdated_by
Because the trigger runs in the database, the audit policy applies equally to:
- JPA dirty checking
Panache.update(...)- native SQL updates
- scripts / DB tools / other services
sequenceDiagram
participant Client
participant REST as Quarkus REST API
participant Tx as Transaction Listener
participant PG as PostgreSQL
participant Trigger as Audit Trigger
Client->>REST: HTTP request + X-Audit-User
REST->>Tx: @Transactional method starts
Tx->>PG: set_config('app.user', 'alice', true)
REST->>PG: entity update / Panache.update / native update
PG->>Trigger: BEFORE INSERT/UPDATE
Trigger->>PG: fill created_* / updated_*
PG-->>REST: rows updated
REST-->>Client: response with audited data
A polished SVG version also lives in docs/audit-flow.svg.
This repo is intentionally designed as a public GitHub reference implementation, not a toy snippet.
It shows:
- a reusable PostgreSQL trigger function
- transparent transaction-scoped user propagation in Quarkus
- clean separation between audit infrastructure and domain code
- REST endpoints that demonstrate three write paths
- tests that verify the audit behavior end-to-end
So instead of debating "should we remember to update updated_by in every query?", you can point teammates to a working repo.
- Java 21
- Quarkus 3.32.x
- Hibernate ORM with Panache
- PostgreSQL
- Flyway
- Quarkus REST + Jackson
- SmallRye OpenAPI
- Spotless (Palantir Java Format)
src
├── main
│ ├── java/io/github/nelsoncc/auditdemo
│ │ ├── audit
│ │ │ ├── AuditRequestContext.java
│ │ │ ├── AuditUserCaptureFilter.java
│ │ │ ├── AuditUserProvider.java
│ │ │ └── PostgresAuditTransactionListener.java
│ │ └── product
│ │ ├── dto
│ │ ├── Product.java
│ │ ├── ProductMapper.java
│ │ ├── ProductRepository.java
│ │ ├── ProductResource.java
│ │ └── ProductService.java
│ └── resources
│ ├── application.properties
│ └── db/migration/V1__create_audit_demo_schema.sql
└── test
└── java/io/github/nelsoncc/auditdemo/product/ProductResourceTest.java
- Java 21+
- Docker
./mvnw quarkus:devQuarkus Dev Services will start PostgreSQL automatically.
Useful URLs:
- API:
http://localhost:8080 - Swagger UI:
http://localhost:8080/q/swagger-ui - OpenAPI:
http://localhost:8080/q/openapi
curl -s -X POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-H "X-Audit-User: alice" \
-d '{"name":"tmp-alpha"}' | jq
curl -s -X POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-H "X-Audit-User: alice" \
-d '{"name":"tmp-beta"}' | jqcurl -s -X PUT http://localhost:8080/products/1/rename-managed \
-H "Content-Type: application/json" \
-H "X-Audit-User: bob" \
-d '{"newName":"tmp-alpha-renamed"}' | jqcurl -s -X POST http://localhost:8080/products/archive/panache \
-H "Content-Type: application/json" \
-H "X-Audit-User: carol" \
-d '{"prefix":"tmp-"}' | jqcurl -s -X POST http://localhost:8080/products/unarchive/native \
-H "Content-Type: application/json" \
-H "X-Audit-User: dave" \
-d '{"prefix":"tmp-"}' | jqcurl -s http://localhost:8080/products | jqYou should see:
created_byset on insertupdated_bychanging per request user- audit columns changing even for bulk updates
| Method | Path | Purpose |
|---|---|---|
POST |
/products |
Create a product |
GET |
/products |
List all products |
GET |
/products/{id} |
Fetch a single product |
PUT |
/products/{id}/rename-managed |
Update through managed entity dirty checking |
POST |
/products/archive/panache |
Bulk update via Panache.update(...) |
POST |
/products/unarchive/native |
Bulk update via native SQL |
The entity maps audit columns as:
@Column(insertable = false, updatable = false)That avoids split-brain ownership between Hibernate and PostgreSQL.
The trigger always overwrites audit columns — application code cannot bypass the audit policy, not even with raw SQL. This is intentional: audit integrity should not depend on callers remembering to do the right thing.
The transaction listener sets app.user at transaction start. Developers do not need to remember to patch every update query manually.
The same trigger function can be attached to any table that follows the same audit column convention.
After HQL/native bulk updates, the service clears the persistence context before re-reading rows. This avoids returning stale in-memory state.
This repo uses a single trigger function for both insert and update:
-
On
INSERT- set
created_atandcreated_by - initialize
updated_atandupdated_by
- set
-
On
UPDATE- refresh
updated_at - refresh
updated_by
- refresh
This gives a complete audit story without scattering timestamp/user code across services.
Because ORM callbacks are not enough once a codebase starts using query-based bulk updates.
That means solutions such as:
@PreUpdate@EntityListeners@UpdateTimestamp
are useful for managed entities, but not sufficient as the global audit strategy.
If you want a team-safe, query-safe, tool-safe solution, the database is the right boundary.
This is a demo, but the pattern is production-worthy.
Recommended hardening steps:
- read the current user from real authentication instead of only
X-Audit-User - propagate technical users for schedulers / batch jobs
- add the same trigger to all audited tables
- document the audit column convention clearly
- avoid letting application code write audit columns directly
- monitor long-running bulk updates as normal database operations
./mvnw verifyThe tests are independent of each other — JUnit 5 does not guarantee execution order, and these tests do not depend on any shared state. Each test creates its own data.
The tests exercise:
| Test | What it proves |
|---|---|
shouldCreateProductWithAuditColumnsFilledByTrigger |
INSERT trigger fills all four audit columns |
shouldUpdateAuditColumnsForManagedAndBulkPaths |
updatedBy changes correctly across managed entity update, Panache bulk update, and native SQL update |
shouldFallbackToSystemWhenNoAuditHeaderIsProvided |
Without X-Audit-User, the trigger defaults to system |
The minimum reusable pieces are:
- the trigger function
- the table triggers
- the transaction listener that sets
app.user - a provider that resolves the current user
- read-only audit field mapping in entities
Everything else in this repository is there to make the idea easy to run, inspect, and explain.
Official docs that support the core idea behind this repository:
- Jakarta Persistence
@PreUpdate: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/preupdate - Quarkus transactions and
@Initialized(TransactionScoped.class): https://quarkus.io/guides/transaction - Hibernate
Session#doWork(...): https://docs.hibernate.org/orm/6.5/javadocs/org/hibernate/Session.html - PostgreSQL
set_config/current_setting: https://www.postgresql.org/docs/current/functions-admin.html
These are the four pillars of the pattern: JPA explains the limitation, Quarkus provides the transaction lifecycle hook, Hibernate provides safe access to the JDBC connection, and PostgreSQL stores the audit truth.
MIT