When building audit logging for your application, one of the first decisions is whether to log synchronously or asynchronously. Here's what we learned implementing both patterns.
The Two Patterns
Synchronous Logging
@Transactional(propagation = Propagation.REQUIRES_NEW)
public AuditLogEntity log(UserEntity actor, String action, ...) {
AuditLogEntity auditLog = AuditLogEntity.builder()
.actorId(actor.getId())
.action(action)
// ... more fields
.build();
return auditLogRepository.save(auditLog);
}
Characteristics:
- Blocks until the audit log is written
- Caller waits for database INSERT
- Transaction completes before method returns
Asynchronous Logging
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAsync(UserEntity actor, String action, ...) {
try {
log(actor, action, ...);
} catch (Exception e) {
log.error("Failed to log audit event: {}", e.getMessage());
}
}
Characteristics:
- Returns immediately
- Audit write happens in background thread pool
- Main operation isn't blocked by audit I/O
When to Use Sync
Use synchronous audit logging when:
1. Audit is Legally Required Before Response
Some compliance frameworks require proof that the audit was recorded before confirming an action:
// Admin disabling a user - must be logged before response
AuditLogEntity auditEntry = auditService.log(admin, ACTION_USER_DISABLED,
TARGET_TYPE_USER, userId, "Violated ToS");
// Now we can confirm to the admin
return ResponseEntity.ok("User disabled. Audit ID: " + auditEntry.getId());
2. You Need the Audit Log ID
If your response includes the audit reference:
AuditLogEntity audit = auditService.log(...);
return ResponseBody.builder()
.response(result)
.auditTrailId(audit.getId()) // Can't do this with async
.build();
3. Admin/Privileged Operations
Admin actions are less frequent and more sensitive. The extra latency is acceptable:
// AdminController - all operations use sync
auditService.log(admin, ACTION_URL_SAFETY_OVERRIDE, TARGET_TYPE_URL, urlId, reason);
auditService.logWithValues(admin, ACTION_USER_SUBSCRIPTION_CHANGE,
TARGET_TYPE_USER, userId, before, after, reason);
When to Use Async
Use asynchronous audit logging when:
1. User-Facing API Endpoints
Don't make users wait for audit I/O:
// UrlController - user creating a short URL
UrlEntity created = urlService.createUrl(urlEntity, user.getId(), tenantId);
// Fire and forget - user gets response immediately
auditService.logAsync(user, ACTION_URL_CREATED, TARGET_TYPE_URL,
created.getId(), created.getSlug(),
null, Map.of("shortUrl", created.getShortUrl(), "longUrl", created.getLongUrl()),
null, null, null, null);
return ResponseEntity.status(HttpStatus.CREATED).body(responseBody);
2. High-Throughput Operations
Bulk operations especially benefit:
// Bulk import - one audit log for the whole batch
if (successCount > 0) {
auditService.logAsync(user, ACTION_URL_BULK_IMPORT, TARGET_TYPE_URL,
null, null, null,
Map.of("totalProcessed", results.size(),
"successCount", successCount,
"failureCount", failureCount),
null, null, null, null);
}
3. Non-Critical Audit Events
Operational logs that are nice-to-have:
// Bio link reordering - minor operation
auditService.logAsync(user, ACTION_BIO_LINK_REORDERED, TARGET_TYPE_BIO,
bioPage.getId(), bioPage.getSlug(),
null, Map.of("linkId", linkId, "direction", direction),
null, null, null, null);
The REQUIRES_NEW Secret Sauce
Both patterns use Propagation.REQUIRES_NEW:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAsync(...) { ... }
This ensures the audit log commits in its own transaction. Why?
- Main transaction rollback - If the business operation fails, we still log the attempt
- Audit failure isolation - If audit logging fails, it doesn't roll back the main operation
- Connection management - Gets its own connection from the pool
Error Handling Difference
Sync: Propagate or Handle
// Option 1: Let it bubble up
auditService.log(...); // Throws if it fails
// Option 2: Handle explicitly
try {
auditService.log(...);
} catch (Exception e) {
log.error("Audit failed, but continuing: {}", e.getMessage());
}
Async: Always Catch
The async method must catch exceptions - they can't propagate to the caller:
@Async
public void logAsync(...) {
try {
log(...);
} catch (Exception e) {
// Log it, alert it, but can't throw to caller
log.error("Failed to log audit event: {}", e.getMessage());
// Maybe send to dead letter queue for retry
}
}
Our Split: 90% Async, 10% Sync
After implementing 67 audit points:
| Controller Type | Pattern | Count |
|---|---|---|
| User-facing | Async | 57 |
| Admin | Sync | 10 |
The result: Fast user APIs with reliable admin audit trails.
Whats your audit story? Always looking for ways to improve.
Building jo4.io - URL shortener with analytics
Top comments (0)