Practical AOP: Logging & Auditing
The real power of AOP reveals itself when you sit down to solve a concrete problem. Logging and auditing are the canonical examples — they are needed everywhere, they are repetitive, and mixing them into business code makes both harder to test and harder to change. In this lesson you will build a production-quality logging and auditing aspect using Spring AOP. You will see how to extract method signatures, capture arguments and return values, handle exceptions, and write structured audit records — all without a single line of tracking code inside your service classes.
Why Logging Belongs in an Aspect
Consider what a typical "logged" service method looks like without AOP:
public OrderDto createOrder(CreateOrderRequest request) {
log.info("createOrder called by user={} with itemCount={}", currentUser(), request.getItems().size());
long start = System.currentTimeMillis();
try {
OrderDto result = orderService.create(request);
log.info("createOrder succeeded in {}ms, orderId={}", System.currentTimeMillis() - start, result.getId());
return result;
} catch (Exception ex) {
log.error("createOrder failed for user={}: {}", currentUser(), ex.getMessage(), ex);
throw ex;
}
}
This is noise. The business logic is buried inside timing and logging calls. Multiply this across fifty service methods and you have a maintenance problem. An aspect centralises all of this.
Setting Up: the Custom Annotation Approach
Rather than logging every method in the application, a professional pattern is to use a marker annotation. Methods that need logging are simply annotated; the aspect intercepts only them.
First, define the annotation:
package com.example.aop;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Audited {
String action() default ""; // e.g. "CREATE_ORDER", "DELETE_USER"
}
The RetentionPolicy.RUNTIME is mandatory — Spring AOP reads annotations at runtime via reflection. ElementType.METHOD restricts it to methods.
Building the Logging Aspect
The aspect itself uses @Around advice so it can capture both the start time and the outcome (success or exception) in a single method:
package com.example.aop;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.util.Arrays;
@Aspect
@Component
@Slf4j
public class AuditLoggingAspect {
@Around("@annotation(audited)")
public Object auditMethod(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
MethodSignature sig = (MethodSignature) pjp.getSignature();
String cls = sig.getDeclaringType().getSimpleName();
String method = sig.getName();
String action = audited.action().isEmpty() ? method : audited.action();
Object[] args = pjp.getArgs();
log.info("[AUDIT] action={} class={} method={} args={}",
action, cls, method, Arrays.toString(args));
long start = System.nanoTime();
try {
Object result = pjp.proceed();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
log.info("[AUDIT] action={} SUCCESS durationMs={} result={}",
action, elapsedMs, summarise(result));
return result;
} catch (Throwable ex) {
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
log.error("[AUDIT] action={} FAILURE durationMs={} error={}",
action, elapsedMs, ex.getMessage(), ex);
throw ex; // re-throw so the caller still handles it
}
}
private String summarise(Object obj) {
if (obj == null) return "null";
String s = obj.toString();
return s.length() > 120 ? s.substring(0, 120) + "..." : s;
}
}
Binding the annotation to the advice parameter: The parameter name audited in auditMethod(ProceedingJoinPoint pjp, Audited audited) must match the name used in the pointcut expression @annotation(audited). Spring AOP uses this binding to inject the actual annotation instance, giving you access to its attributes (audited.action()) without extra reflection calls.
Applying the Annotation to Service Methods
Now annotate the methods that need audit trails:
@Service
public class OrderService {
@Audited(action = "CREATE_ORDER")
public OrderDto createOrder(CreateOrderRequest request) {
// pure business logic, zero logging code
return orderRepository.save(mapper.toEntity(request));
}
@Audited(action = "CANCEL_ORDER")
public void cancelOrder(Long orderId) {
orderRepository.cancelById(orderId);
}
}
The service class is now clean. All observability behaviour lives in the aspect.
Persisting Audit Records to the Database
For compliance and security scenarios, log lines are not enough — you need durable, queryable audit records. Extend the aspect to write to an AuditLog table:
// AuditLog entity (Jakarta Persistence / Spring Data JPA)
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String action;
private String className;
private String methodName;
private String args; // JSON or truncated toString
private String outcome; // SUCCESS | FAILURE
private Long durationMs;
private String errorMessage;
private Instant performedAt;
// getters/setters or use Lombok @Data
}
// Repository
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {}
Inject the repository into the aspect and persist after each call:
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class AuditLoggingAspect {
private final AuditLogRepository auditLogRepo;
@Around("@annotation(audited)")
public Object auditMethod(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
MethodSignature sig = (MethodSignature) pjp.getSignature();
String action = audited.action().isEmpty() ? sig.getName() : audited.action();
long start = System.nanoTime();
String outcome;
String errorMsg = null;
try {
Object result = pjp.proceed();
outcome = "SUCCESS";
return result;
} catch (Throwable ex) {
outcome = "FAILURE";
errorMsg = ex.getMessage();
throw ex;
} finally {
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
persist(sig, action, Arrays.toString(pjp.getArgs()), outcome, elapsedMs, errorMsg);
}
}
private void persist(MethodSignature sig, String action, String args,
String outcome, long durationMs, String errorMsg) {
try {
AuditLog entry = new AuditLog();
entry.setAction(action);
entry.setClassName(sig.getDeclaringType().getSimpleName());
entry.setMethodName(sig.getName());
entry.setArgs(args.length() > 500 ? args.substring(0, 500) : args);
entry.setOutcome(outcome);
entry.setDurationMs(durationMs);
entry.setErrorMessage(errorMsg);
entry.setPerformedAt(Instant.now());
auditLogRepo.save(entry);
} catch (Exception e) {
// Never let the audit mechanism crash the calling thread
log.warn("Failed to persist audit record for action={}: {}", action, e.getMessage());
}
}
}
Wrap persistence in its own try-catch. If auditLogRepo.save() throws (e.g. the database is down), you must not propagate that exception — doing so would convert a successful business operation into an apparent failure. Audit is observability infrastructure; it must never break primary flows.
Adding the Current User to Audit Records
Audit records are most useful when they capture who performed the action. In a Spring Security application, the current principal is available via SecurityContextHolder:
private String currentUser() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return "anonymous";
return auth.getName();
}
Add a performedBy column to AuditLog and populate it from currentUser() inside the aspect. This gives you a full audit trail: who did what, when, how long it took, and whether it succeeded.
Logging Method Entry and Exit with @Before / @AfterReturning
Sometimes you want lighter-weight per-layer logging — trace-level entry/exit logs for debugging — without the overhead of @Around. You can combine @Before and @AfterReturning on a package-scoped pointcut:
@Aspect
@Component
@Slf4j
public class TraceLoggingAspect {
@Pointcut("execution(* com.example.service..*(..))")
private void serviceLayer() {}
@Before("serviceLayer()")
public void logEntry(JoinPoint jp) {
if (log.isDebugEnabled()) {
log.debug("--> {}.{}({})",
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName(),
Arrays.toString(jp.getArgs()));
}
}
@AfterReturning(pointcut = "serviceLayer()", returning = "result")
public void logExit(JoinPoint jp, Object result) {
if (log.isDebugEnabled()) {
log.debug("<-- {}.{} returned {}",
jp.getTarget().getClass().getSimpleName(),
jp.getSignature().getName(),
result);
}
}
}
Guard debug logging with isDebugEnabled(). Constructing the argument string (especially Arrays.toString(jp.getArgs())) has a small but real cost. Wrapping it in a level check means zero overhead when the log level is INFO or higher in production.
Design Trade-offs
- Annotation-driven vs package-scoped pointcuts: Annotations are explicit — a developer deliberately opts in, so no methods are accidentally audited. Package-scoped pointcuts are implicit but exhaustive — useful for trace logging but risky for audit trails where you need precise control.
- Sensitive arguments: Never log raw passwords, tokens, or PII. Apply a redaction utility before logging args, or use a separate annotation like
@Masked on parameters.
- Async persistence: For high-throughput services, writing to the audit table synchronously adds latency to every request. Consider publishing an
AuditEvent to an application event or a message queue and persisting it off the critical path.
- Transaction boundaries: The audit
save() runs inside the calling method's transaction by default. If the business transaction rolls back, so does the audit record. Use @Transactional(propagation = Propagation.REQUIRES_NEW) on the persist helper if you need audit records to survive rollbacks.
Summary
A logging and auditing aspect is one of the highest-value cross-cutting concerns you can introduce to a Spring Boot application. By combining a custom @Audited annotation with an @Around advice, you capture entry, outcome, duration, and caller identity in one reusable place. The service classes stay clean, tests remain focused on business logic, and the audit policy can evolve — more columns, async persistence, external sinks — without touching a single service. In the next lesson you will apply the same pattern to performance monitoring and method-level security.