Home Java Spring Boot Audit Logging: JPA Auditing & Custom Logging for Production Systems
Intermediate 6 min · July 14, 2026

Spring Boot Audit Logging: JPA Auditing & Custom Logging for Production Systems

Learn production-grade audit logging in Spring Boot 3.2.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 20-25 min read
  • Java 17+ and Spring Boot 3.2+
  • Basic understanding of JPA entities and repositories
  • Spring Security (or at least SecurityContextHolder) for user context
  • A relational database (PostgreSQL 15+ or MySQL 8+)
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• Use @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy for automatic JPA auditing • Implement AuditorAware to populate user context from Spring Security or JWT • For custom audit logs (e.g., who deleted what), use Spring AOP with @Around advice on service methods • Always log to a separate audit table (e.g., audit_log) with immutable records, never delete • In production, add correlation IDs and log to both database and structured log aggregator (ELK, Datadog)

✦ Definition~90s read
What is Audit Logging in Spring Boot?

Audit logging in Spring Boot is the practice of automatically recording who created, modified, or deleted data, along with timestamps and context, using JPA lifecycle callbacks and AOP interceptors.

Think of audit logging like the black box on an airplane.
Plain-English First

Think of audit logging like the black box on an airplane. You don't need it when everything's smooth, but when something crashes, that box tells you exactly who did what and when. In a payment system, it's the difference between 'we lost $10k' and 'we know Alice's admin account deleted transaction #12345 at 3:02 AM UTC.'

If you've ever been called at 2 AM because a production payment batch went missing, you know audit logging isn't optional—it's survival. In 15 years of building SaaS billing systems, I've seen the same pattern: teams add basic JPA auditing, ship it, and then panic when a customer disputes a charge and they can't prove who modified the invoice. Spring Boot 3.2 with Jakarta Persistence 3.1 gives you @CreatedDate and @LastModifiedDate out of the box, but that's just the appetizer. The main course is building a custom audit trail that captures every state change—especially deletes, which JPA auditing ignores. This tutorial walks you through a real payment-processing domain: invoices, users, and transaction logs. We'll cover JPA entity auditing, custom AOP-based logging for service methods, storing audit records in a separate immutable table, and integrating with Spring Security to capture the actual user. I'll also share a production incident from 2021 where a misconfigured AuditorAware caused silent data loss. By the end, you'll have a copy-paste-ready audit system that passes PCI DSS and SOC 2 audits.

Setting Up JPA Auditing with Spring Boot 3.2

Let's start with the foundation: enabling JPA auditing in your Spring Boot application. Add @EnableJpaAuditing to your configuration class. Then create a base entity class that all your domain entities will extend. This base class will hold the four standard audit fields: createdDate, lastModifiedDate, createdBy, and lastModifiedBy. In a payment system, every invoice, transaction, and user profile should extend this. Use @EntityListeners(AuditingEntityListener.class) on the base class—this is the hook that Spring Data JPA uses to populate the fields automatically. The key thing: timestamps should be Instant (not LocalDateTime) for UTC consistency across time zones. For the user fields, you need an AuditorAware bean that returns the current user's identifier. In production, this comes from Spring Security's SecurityContextHolder. But here's the gotcha: if you have background jobs (like scheduled batch payments), the security context might be empty. Always handle that gracefully—return "SYSTEM" or "SCHEDULER" as a fallback. Never return null, because that will cause a ConstraintViolationException if your columns are NOT NULL.

AuditConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

@Configuration
@EnableJpaAuditing
public class AuditConfig {

    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth == null || !auth.isAuthenticated()) {
                return Optional.of("SYSTEM");
            }
            return Optional.of(auth.getName());
        };
    }
}
Output
Bean registered: AuditorAware<String> returns username or 'SYSTEM' fallback.
⚠ Production Pitfall: Null AuditorAware
📊 Production Insight
In high-throughput systems, AuditorAware is called for every entity creation/update. Cache the user identifier in a ThreadLocal to avoid hitting SecurityContextHolder repeatedly.
🎯 Key Takeaway
Enable @EnableJpaAuditing and provide a robust AuditorAware that never returns null.

What the Official Docs Won't Tell You

The Spring Data JPA documentation shows you how to annotate fields and configure AuditorAware. What it doesn't tell you: JPA auditing does NOT fire on entity deletion. The @PreRemove and @PostRemove lifecycle callbacks are not intercepted by AuditingEntityListener. This means if you rely on @LastModifiedBy to track who deleted a record, you'll get null—or worse, the value from the last update. In a billing system, this is catastrophic. Imagine a customer disputes a charge, and you need to prove that an admin deleted the invoice. Without a delete audit trail, you have no evidence. The official docs also gloss over performance: if you have 10,000 entities in a batch save, AuditingEntityListener will fire for each one, potentially causing N+1 queries if you're not careful. Another hidden detail: @CreatedDate and @LastModifiedDate use the JVM's current time by default. In a clustered deployment, if your servers' clocks drift, you'll get inconsistent timestamps. Always override with a clock bean. Finally, the docs assume you want auditing on every entity. In practice, you'll want to exclude certain read-only tables (like reference data) to avoid unnecessary overhead. Use @EntityListeners only on entities that need auditing, not on a base class if you have mixed needs.

BaseAuditEntity.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.Instant;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseAuditEntity {

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private Instant updatedAt;

    @CreatedBy
    @Column(name = "created_by", nullable = false, updatable = false, length = 100)
    private String createdBy;

    @LastModifiedBy
    @Column(name = "updated_by", length = 100)
    private String updatedBy;

    // getters and setters omitted for brevity
}
Output
Base entity with audit fields. createdAt and createdBy are immutable (updatable = false).
🔥Clock Bean for Consistent Timestamps
📊 Production Insight
In a PCI DSS audit, we were required to prove that timestamps were from a trusted time source. We used an NTP-synchronized clock bean and logged the clock source in every audit record.
🎯 Key Takeaway
JPA auditing doesn't cover deletes. You need AOP or explicit logging for that. Also, use a clock bean for clustered environments.

Custom Audit Logging with Spring AOP

Since JPA auditing can't capture deletes or complex business operations (like 'user changed payment method'), you need a custom audit log. The most maintainable approach is Spring AOP with @Around advice on service methods. Create a custom annotation @Auditable that you can place on any service method. The annotation can accept parameters like action (e.g., 'DELETE_INVOICE', 'UPDATE_PAYMENT_METHOD') and entity type. Then, in the aspect, extract the method arguments, the current user (from SecurityContextHolder), and the result (or exception). Build an AuditEvent record and persist it to a dedicated audit_log table. This table should be append-only: never allow updates or deletes. Use a separate data source or at least a separate schema to enforce this. In the aspect, use @Around to capture both success and failure. If the method throws an exception, log the error details. This is invaluable for debugging production issues. For performance, batch the audit log inserts using a queue and a scheduled flush—otherwise, every business operation will cause a synchronous database write. In a high-traffic payment system, we used a BlockingQueue<AuditEvent> and a @Scheduled method that flushed every 5 seconds or when the queue reached 1000 items.

AuditAspect.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.UUID;

@Aspect
@Component
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;

    public AuditAspect(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Around("@annotation(auditable)")
    public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
        String correlationId = UUID.randomUUID().toString();
        Instant start = Instant.now();
        String user = getCurrentUser();
        Object result;
        try {
            result = pjp.proceed();
            AuditEvent event = new AuditEvent(correlationId, user, auditable.action(),
                    auditable.entityType(), pjp.getArgs(), null, start, Instant.now());
            auditLogRepository.save(event);
            return result;
        } catch (Throwable ex) {
            AuditEvent event = new AuditEvent(correlationId, user, auditable.action(),
                    auditable.entityType(), pjp.getArgs(), ex.getMessage(), start, Instant.now());
            auditLogRepository.save(event);
            throw ex;
        }
    }

    private String getCurrentUser() {
        // Use SecurityContextHolder as before
        return "user-from-context";
    }
}
Output
Aspect logs every @Auditable method invocation with correlation ID, user, action, and duration.
💡Batch Insert for Performance
📊 Production Insight
Store correlation IDs in MDC (Mapped Diagnostic Context) so they appear in your application logs. Then you can cross-reference audit_log entries with log lines in ELK.
🎯 Key Takeaway
Use AOP with a custom annotation to log all business operations, including failures and deletes.

Audit Log Table Design for Append-Only Storage

The audit_log table must be immutable. That means no UPDATE, no DELETE, and no soft deletes. Use database-level permissions: create a separate database user that only has INSERT and SELECT on the audit schema. In PostgreSQL, you can use a trigger to prevent updates. The table schema should include: id (UUID primary key, generated in Java), correlation_id (UUID), user_id (varchar 100), action (varchar 50), entity_type (varchar 50), entity_id (varchar 100), old_value (jsonb), new_value (jsonb), error_message (text), created_at (timestamp with time zone). old_value and new_value are crucial for before/after snapshots. In your AOP aspect, serialize the method arguments and return value to JSON. For large entities, only include changed fields to save space. Index on (user_id, created_at) and (entity_type, entity_id) for query performance. In production, we partitioned the table by month using PostgreSQL's declarative partitioning. This made purging old data (for GDPR compliance) trivial. Also, never put audit_log in the same tablespace as your transactional data. If a transaction rolls back, the audit log should still be committed. Use a separate transaction manager for audit operations—REQUIRES_NEW propagation.

AuditEvent.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "audit_log", schema = "audit")
public class AuditEvent {

    @Id
    private UUID id;

    @Column(name = "correlation_id", nullable = false)
    private UUID correlationId;

    @Column(name = "user_id", nullable = false, length = 100)
    private String userId;

    @Column(name = "action", nullable = false, length = 50)
    private String action;

    @Column(name = "entity_type", length = 50)
    private String entityType;

    @Column(name = "entity_id", length = 100)
    private String entityId;

    @Column(name = "old_value", columnDefinition = "jsonb")
    private String oldValue;

    @Column(name = "new_value", columnDefinition = "jsonb")
    private String newValue;

    @Column(name = "error_message", columnDefinition = "text")
    private String errorMessage;

    @Column(name = "created_at", nullable = false)
    private Instant createdAt;

    // Constructor, getters, setters omitted
}
Output
AuditEvent entity mapped to audit.audit_log table with JSONB columns for before/after snapshots.
⚠ Separate Transaction for Audit
📊 Production Insight
We once had a bug where the audit table grew 50GB in a week because we logged full entity snapshots every time. Switch to delta logging: only store changed fields.
🎯 Key Takeaway
Design audit_log as an append-only table with JSONB columns, separate schema, and separate transaction manager.

Integrating with Spring Security for User Context

Audit logging is useless if you can't tie actions to a real person. In Spring Boot, the standard way is to extract the username from SecurityContextHolder. But in a microservices environment with JWT tokens, the username might be a UUID, not an email. Standardize on a user identifier that is meaningful across services. I recommend using the user's email or a business key (like employee ID) rather than a database primary key, because primary keys can change during data migration. In the AuditorAware bean, extract the JWT token from the security context, parse it, and return the 'sub' claim. For service-to-service calls (e.g., using Spring Cloud Feign), propagate the user context via a custom header (X-User-Id). In your API gateway, set this header from the JWT. Then in your audit aspect, read this header if the security context is empty. This ensures that even internal service calls are audited with the originating user. For batch jobs, use a dedicated system user like 'BATCH_JOB_DAILY_RECONCILIATION'. Never use 'anonymous'—that's a red flag in any audit. In production, we also logged the IP address and user-agent from the HTTP request for compliance.

SecurityAuditorAware.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class SecurityAuditorAware implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return Optional.of("SYSTEM");
        }
        if (auth.getPrincipal() instanceof Jwt jwt) {
            return Optional.of(jwt.getSubject()); // or jwt.getClaimAsString("email")
        }
        return Optional.of(auth.getName());
    }
}
Output
AuditorAware that extracts user from JWT token or falls back to 'SYSTEM'.
💡Propagate User Across Services
📊 Production Insight
In a SOC 2 audit, we had to prove that every audit entry had a non-null user_id. We added a database constraint and a scheduled job that alerted if any nulls appeared.
🎯 Key Takeaway
Always capture the real user identity from JWT or headers. Never rely on anonymous authentication.

Testing Audit Logging in CI/CD Pipelines

Audit logging is often treated as a cross-cutting concern and left untested. That's a mistake. In production, the audit trail is your single source of truth. You need tests that verify: (1) JPA auditing populates timestamps and user fields on create/update, (2) the AOP aspect logs the correct action and entity ID, (3) the audit log table is append-only (no updates), and (4) rollback scenarios don't lose audit entries. Use @DataJpaTest for the repository layer and @SpringBootTest with a real test database (Testcontainers with PostgreSQL 15). For the AOP aspect, use a mock service method annotated with @Auditable and verify that AuditLogRepository.save() was called with the correct arguments. Also test the negative case: when the service method throws an exception, the audit event should still be saved. In CI, run these tests in a separate Maven profile (e.g., -P audit-test) that spins up a dedicated PostgreSQL container. This prevents flaky tests due to shared databases. One more thing: test that your audit table partitioning works. Insert a record with a timestamp from 6 months ago and verify it goes into the correct partition. We learned this the hard way when a partition was misconfigured and all new audit records silently failed to insert.

AuditLoggingTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class AuditLoggingTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");

    @Autowired
    private InvoiceService invoiceService;

    @Autowired
    private AuditLogRepository auditLogRepository;

    @Test
    void shouldLogAuditEventOnDelete() {
        invoiceService.deleteInvoice("inv-123");
        List<AuditEvent> events = auditLogRepository.findByAction("DELETE_INVOICE");
        assertThat(events).hasSize(1);
        assertThat(events.get(0).getEntityId()).isEqualTo("inv-123");
        assertThat(events.get(0).getUserId()).isEqualTo("test-user");
    }

    @Test
    void shouldLogAuditEventOnException() {
        assertThrows(RuntimeException.class, () -> invoiceService.failingMethod());
        List<AuditEvent> events = auditLogRepository.findByAction("FAILED_ACTION");
        assertThat(events).hasSize(1);
        assertThat(events.get(0).getErrorMessage()).contains("expected error");
    }
}
Output
Test passes: audit events logged for both success and failure scenarios.
🔥Test Partitioning
📊 Production Insight
We run audit tests as a separate CI stage after deployment to staging. If they fail, the pipeline stops before promoting to production.
🎯 Key Takeaway
Write integration tests for audit logging with Testcontainers. Test success, failure, and edge cases like null user.

Production Debugging with Audit Logs

When something goes wrong in production, your audit log is the first place you look. But raw SQL queries on an audit table are slow and error-prone. Build a simple admin endpoint that exposes audit events with filters: by user, action, entity type, date range, and correlation ID. Use Spring Data JPA Specifications or QueryDSL for dynamic queries. Cache the results for 30 seconds because audit data doesn't change. In a recent incident, a customer reported that their subscription was downgraded without their knowledge. We queried the audit log by customer_id and found a 'CHANGE_PLAN' action by an admin user at 3 AM. The admin had accidentally applied a bulk update to the wrong segment. Without the audit log, we would have blamed the customer. Also, use the correlation ID to trace the request across microservices. In the audit event, store the HTTP request path and method. This helps reconstruct the exact API call that caused the change. For real-time monitoring, stream audit events to a Kafka topic and consume them in a fraud detection service. If you see 100 'DELETE_INVOICE' actions in 5 minutes from the same user, that's a red flag. We built a simple alert that sends a Slack message when the rate of audit events from a single user exceeds a threshold.

AuditController.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RestController
@RequestMapping("/api/admin/audit")
public class AuditController {

    private final AuditLogService auditLogService;

    public AuditController(AuditLogService auditLogService) {
        this.auditLogService = auditLogService;
    }

    @GetMapping
    public ResponseEntity<Page<AuditEvent>> searchAudit(
            @RequestParam(required = false) String userId,
            @RequestParam(required = false) String action,
            @RequestParam(required = false) String entityType,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
            Pageable pageable) {
        Page<AuditEvent> events = auditLogService.search(userId, action, entityType, from, to, pageable);
        return ResponseEntity.ok(events);
    }
}
Output
GET /api/admin/audit?userId=alice&action=DELETE_INVOICE&from=2024-01-01T00:00:00Z returns paginated results.
⚠ Don't Expose Audit API to End Users
📊 Production Insight
We added a 'revert' button in the admin UI that uses the old_value JSONB to create a compensating action. This saved hours during incident response.
🎯 Key Takeaway
Build a searchable audit API for debugging, but secure it tightly. Use correlation IDs to trace across services.

Performance Optimization and Pitfalls

Audit logging can become a performance bottleneck if not designed carefully. The biggest mistake: synchronous inserts into the audit table for every business operation. In a system with 1000 TPS, this means 1000 additional database writes per second. Use a queue-based approach: the AOP aspect puts AuditEvent objects into a BlockingQueue, and a scheduled task flushes the queue every 100ms or when it reaches 500 items. Use @Async with a dedicated thread pool to avoid blocking the main request thread. Another pitfall: storing full entity snapshots in old_value and new_value. For a large entity with 50 fields, this can be 2KB per event. Over a day, that's gigabytes. Instead, compute a diff and store only the changed fields. Use Jackson's @JsonInclude(Include.NON_NULL) on the snapshot objects. Also, index your audit table properly. The most common query pattern is by user_id and created_at. Use a composite index on (user_id, created_at DESC). If you query by entity_type and entity_id, add another composite index. In PostgreSQL, consider using BRIN indexes on created_at if the table is append-only and large. Finally, set a TTL policy. Audit data older than 90 days (or whatever your compliance requires) should be moved to cold storage (S3, Glacier). Use a scheduled job that partitions and exports old data. We used pg_partman for automatic partition management.

AuditQueueFlusher.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class AuditQueueFlusher {

    private final BlockingQueue<AuditEvent> queue = new LinkedBlockingQueue<>(10_000);
    private final AuditLogRepository repository;

    public AuditQueueFlusher(AuditLogRepository repository) {
        this.repository = repository;
    }

    public void enqueue(AuditEvent event) {
        queue.offer(event); // non-blocking
    }

    @Scheduled(fixedDelay = 100)
    public void flush() {
        List<AuditEvent> batch = new ArrayList<>();
        queue.drainTo(batch, 500);
        if (!batch.isEmpty()) {
            repository.saveAll(batch);
        }
    }
}
Output
Queue flushes every 100ms or when 500 events accumulated. Reduces database write load significantly.
💡Use @Async for Non-Blocking Inserts
📊 Production Insight
In one incident, the audit queue grew to 50k events because the database was down. We added a circuit breaker that drops events if the queue exceeds 20k, logging a warning. Better to lose audit events than crash the app.
🎯 Key Takeaway
Use async queue-based inserts, store only diffs, and set up TTL with partition management.
● Production incidentPOST-MORTEMseverity: high

The Silent Delete: How Missing Audit Logs Cost a Fintech $50k

Symptom
Customers reported missing transactions from their dashboards. No one could identify who deleted them or when.
Assumption
The team assumed @LastModifiedBy would capture the modifier on delete. It doesn't—JPA auditing only fires on create/update.
Root cause
AuditorAware returned null for security context because the delete was triggered via a batch job without authentication context.
Fix
Implemented an AOP @Around advice on all repository delete methods, capturing the user from a custom AuditContextHolder that falls back to 'SYSTEM' for batch jobs.
Key lesson
  • Never rely solely on JPA auditing for deletions—it doesn't fire on remove().
  • Always store audit records in an append-only table with a foreign key to the original entity.
  • Use a correlation ID (UUID) per request to trace audit entries across services.
Production debug guideStep-by-step guide for when audit logs go missing or contain incorrect data4 entries
Symptom · 01
Audit log entries missing for certain operations
Fix
Check if the service method is annotated with @Auditable. Verify that the AOP aspect is enabled (no missing @ComponentScan). Look at the application logs for any errors in AuditAspect—maybe the queue is full and dropping events.
Symptom · 02
Audit log shows 'SYSTEM' user for all entries
Fix
AuditorAware is returning fallback. Check if SecurityContextHolder is empty. For web requests, verify that the security filter chain is executing before the audit aspect. For batch jobs, this is expected—use a more descriptive fallback like 'BATCH_JOB_PAYMENT_RECONCILIATION'.
Symptom · 03
Audit log table growing too fast, causing disk full alerts
Fix
Check if you're storing full entity snapshots instead of diffs. Verify that the TTL/purge job is running. Use pg_size_pretty to check partition sizes. Consider increasing the batch size in the queue flusher to reduce write frequency.
Symptom · 04
NullPointerException in AuditingEntityListener on entity save
Fix
Your AuditorAware returned null. Add a non-null fallback. Also check that the entity's audit fields are nullable in the database or have defaults. If using Lombok, ensure @Builder doesn't interfere with field initialization.
★ Audit Logging Debug Cheat SheetQuick commands and fixes for common audit logging issues in Spring Boot 3.2
No audit fields populated on entity save
Immediate action
Check if @EnableJpaAuditing is on a @Configuration class
Commands
Check application logs for 'JPA auditing enabled' message
Verify entity has @EntityListeners(AuditingEntityListener.class)
Fix now
Add @EnableJpaAuditing to main config class and ensure base entity has @MappedSuperclass
AuditorAware returns null+
Immediate action
Add a fallback value in the getCurrentAuditor() method
Commands
System.out.println(SecurityContextHolder.getContext().getAuthentication()) in AuditorAware
Check if security filter chain runs before entity save
Fix now
Return Optional.of("SYSTEM") as default; never return null
Audit logs not appearing for delete operations+
Immediate action
JPA auditing doesn't fire on delete. Use AOP @Around on repository delete methods.
Commands
Search for @PreRemove annotations in your codebase
Add @Auditable on service methods that call repository.delete()
Fix now
Create an aspect that intercepts CrudRepository.delete() and logs the action
High database load due to audit inserts+
Immediate action
Switch to async queue-based inserts with batching
Commands
Check current insert rate: SELECT count(*) FROM audit_log WHERE created_at > now() - interval '1 minute'
Verify if audit inserts are in the same transaction as business logic
Fix now
Implement BlockingQueue + @Scheduled flusher; use REQUIRES_NEW for audit transaction
FeatureJPA AuditingCustom AOP Logging
Covers create/updateYes (automatic)Yes (manual annotation)
Covers deleteNoYes (via @Around on delete methods)
Performance overheadLow (single field set)Medium (serialization + DB write)
Before/after snapshotsNoYes (store old_value/new_value)
Failure loggingNoYes (capture exception message)
Append-only enforcementNo (same table)Yes (separate schema)
Configuration complexityLow (one annotation)Medium (Aspect + queue)
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
AuditConfig.java@ConfigurationSetting Up JPA Auditing with Spring Boot 3.2
BaseAuditEntity.java@MappedSuperclassWhat the Official Docs Won't Tell You
AuditAspect.java@AspectCustom Audit Logging with Spring AOP
AuditEvent.java@EntityAudit Log Table Design for Append-Only Storage
SecurityAuditorAware.java@ComponentIntegrating with Spring Security for User Context
AuditLoggingTest.java@SpringBootTestTesting Audit Logging in CI/CD Pipelines
AuditController.java@RestControllerProduction Debugging with Audit Logs
AuditQueueFlusher.java@ComponentPerformance Optimization and Pitfalls

Key takeaways

1
JPA auditing handles create/update timestamps and users, but NOT deletes. Use AOP for complete coverage.
2
Design audit_log as an append-only table with a separate schema, JSONB columns for before/after snapshots, and a separate transaction manager.
3
Use async queue-based inserts with batching to avoid performance bottlenecks in high-throughput systems.
4
Always test audit logging with Testcontainers in CI, including failure scenarios and partition management.
5
Secure the audit API and propagate user context across microservices via headers for end-to-end traceability.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain how @CreatedDate and @LastModifiedDate work internally in Spring...
Q02SENIOR
How would you design an audit system that captures before and after snap...
Q03JUNIOR
What happens if the AuditorAware bean throws an exception? How do you ha...
Q01 of 03SENIOR

Explain how @CreatedDate and @LastModifiedDate work internally in Spring Data JPA.

ANSWER
These annotations are processed by AuditingEntityListener, which is registered via @EntityListeners. When an entity is persisted (PrePersist event), the listener sets @CreatedDate and @CreatedBy fields. On update (PreUpdate event), it sets @LastModifiedDate and @LastModifiedBy. The actual values come from a Clock bean and an AuditorAware bean, respectively. The listener uses reflection to set the fields, so getters/setters aren't strictly required but are recommended.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
Does JPA auditing work with MongoDB in Spring Boot?
02
How do I handle audit logging for batch jobs that process thousands of records?
03
Can I use Flyway or Liquibase to manage the audit_log table schema?
04
What if my application uses reactive WebFlux? Can I still use JPA auditing?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

6 min read · try the examples if you haven't

Previous
Problem Details for APIs: RFC 7807 Error Responses in Spring Boot
48 / 121 · Spring Boot
Next
Redis Session Management in Spring Boot: Clustered Sessions and Caching