Project: A Cross-Cutting Concern with AOP
Everything covered in this tutorial comes together in this final project. You will build a production-grade Audit Trail system — a classic cross-cutting concern — and wire it entirely through AOP so that not a single service class needs to know it exists. By the end you will have a reusable, configurable aspect that captures who called what, when, and with what result, and persists that data to a database table, all without touching your business logic.
What You Are Building
The goal is an @Audited annotation that developers can place on any service method. When that method runs, an aspect intercepts the call, records the caller's identity, the method signature, the arguments, the return value or exception, and the execution time, then saves an AuditLog entity. The service class itself changes by exactly one line: the annotation.
Step 1 — Domain and Persistence
Start with the data model. Create an AuditLog JPA entity and its repository.
// AuditLog.java
package com.example.audit.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "audit_logs")
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String principal; // logged-in user or "anonymous"
private String className;
private String methodName;
@Column(length = 2000)
private String arguments; // JSON-serialised args
@Column(length = 2000)
private String result; // JSON-serialised return value or exception message
private boolean success;
private long durationMs;
private Instant timestamp;
// --- getters / setters omitted for brevity ---
}
// AuditLogRepository.java
package com.example.audit.repository;
import com.example.audit.model.AuditLog;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {}
Step 2 — The Custom Annotation
A dedicated annotation makes intent explicit and gives you a clean pointcut target. The @Retention(RUNTIME) and @Target(METHOD) meta-annotations are mandatory for Spring AOP to see it.
// Audited.java
package com.example.audit.annotation;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Audited {
String action() default ""; // optional human-readable label, e.g. "CREATE_ORDER"
}
Step 3 — The Aspect
The aspect is where all the work happens. Use @Around so you can capture both the return value and any thrown exception, and measure elapsed time in one place.
// AuditAspect.java
package com.example.audit.aspect;
import com.example.audit.annotation.Audited;
import com.example.audit.model.AuditLog;
import com.example.audit.repository.AuditLogRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
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.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.Arrays;
@Aspect
@Component
public class AuditAspect {
private final AuditLogRepository repo;
private final ObjectMapper mapper;
public AuditAspect(AuditLogRepository repo, ObjectMapper mapper) {
this.repo = repo;
this.mapper = mapper;
}
@Around("@annotation(audited)")
public Object audit(ProceedingJoinPoint pjp, Audited audited) throws Throwable {
long start = System.currentTimeMillis();
MethodSignature sig = (MethodSignature) pjp.getSignature();
AuditLog log = new AuditLog();
log.setPrincipal(resolvePrincipal());
log.setClassName(sig.getDeclaringTypeName());
log.setMethodName(sig.getName());
log.setArguments(toJson(pjp.getArgs()));
log.setTimestamp(Instant.now());
try {
Object result = pjp.proceed();
log.setResult(toJson(result));
log.setSuccess(true);
return result;
} catch (Throwable ex) {
log.setResult(ex.getClass().getSimpleName() + ": " + ex.getMessage());
log.setSuccess(false);
throw ex; // always re-throw; the aspect must not swallow exceptions
} finally {
log.setDurationMs(System.currentTimeMillis() - start);
repo.save(log); // runs in the same transaction as the service method
}
}
private String resolvePrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return (auth != null && auth.isAuthenticated()) ? auth.getName() : "anonymous";
}
private String toJson(Object value) {
try {
return mapper.writeValueAsString(value);
} catch (Exception e) {
return "[unserializable]";
}
}
}
Why @Around("@annotation(audited)") and not a named pointcut? Binding the annotation parameter directly in the advice signature gives you access to the annotation instance at runtime (so you can read audited.action()). It is more concise than a separate @Pointcut + @Around pair when you only use the pointcut once.
Step 4 — Applying the Annotation to a Service
This is the only change any service class needs. The audit infrastructure is completely invisible to the business logic.
// OrderService.java
package com.example.order;
import com.example.audit.annotation.Audited;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
@Audited(action = "CREATE_ORDER")
public Order createOrder(CreateOrderRequest request) {
// ... purely business logic ...
return savedOrder;
}
@Audited(action = "CANCEL_ORDER")
public void cancelOrder(Long orderId) {
// ... business logic ...
}
}
Step 5 — Async Persistence (Production Refinement)
Writing to the database inside the intercepted call adds latency to every audited method. For high-throughput services, offload the write to a separate thread with @Async.
// AuditLogService.java (new class — keeps @Async out of the aspect)
package com.example.audit.service;
import com.example.audit.model.AuditLog;
import com.example.audit.repository.AuditLogRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuditLogService {
private final AuditLogRepository repo;
public AuditLogService(AuditLogRepository repo) {
this.repo = repo;
}
@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(AuditLog log) {
repo.save(log);
}
}
Use Propagation.REQUIRES_NEW so the audit write commits independently — even if the main transaction rolls back you still have a record that the call was attempted and failed.
Do not call @Async methods from the same class. Spring's proxy-based @Async only applies when the call passes through the proxy. Inject AuditLogService into the aspect and call it from there, not from a method inside the same bean.
Step 6 — Testing the Aspect
Integration-test the full stack with a real Spring context and an H2 in-memory database. Verify that a method call produces exactly one AuditLog row with the expected values.
// AuditAspectIT.java
package com.example.audit;
import com.example.audit.model.AuditLog;
import com.example.audit.repository.AuditLogRepository;
import com.example.order.OrderService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class AuditAspectIT {
@Autowired OrderService orderService;
@Autowired AuditLogRepository repo;
@Test
void createOrder_shouldWriteAuditLog() {
long before = repo.count();
orderService.createOrder(new CreateOrderRequest(...));
List<AuditLog> logs = repo.findAll();
assertThat(logs).hasSize((int) before + 1);
AuditLog log = logs.get(logs.size() - 1);
assertThat(log.getMethodName()).isEqualTo("createOrder");
assertThat(log.isSuccess()).isTrue();
assertThat(log.getDurationMs()).isGreaterThanOrEqualTo(0);
}
}
Design Trade-offs to Know
- Same-transaction vs. independent write: synchronous
repo.save() in the advice ties audit persistence to the business transaction. REQUIRES_NEW + @Async decouples them at the cost of slight eventual consistency.
- Argument serialisation: serialising all arguments to JSON can leak sensitive data (passwords, PII). Prefer an allow-list approach or a custom
toString() on request objects annotated with a masking library.
- Self-invocation: Spring AOP uses proxies — calling an
@Audited method from another method in the same class bypasses the aspect. Restructure to inject the bean into itself, or switch to full AspectJ weaving for internal calls.
- Performance overhead: each proxy invocation adds nanoseconds of overhead. For very hot paths (millions of calls/second) measure the cost; for typical service-layer calls it is negligible.
Build the aspect once, use it everywhere. Once your AuditAspect is in a shared library module, any team in your organisation can add audit trails to their service with a single @Audited annotation and zero boilerplate. That is the real pay-off of cross-cutting concerns done properly.
Summary
You have built a complete, production-quality audit trail system using Spring AOP. The @Audited annotation acts as a declarative contract; the AuditAspect enforces it uniformly across every annotated method. The service layer stays clean and focused on business logic. Optional async persistence with REQUIRES_NEW keeps audit writes durable even when business transactions roll back. This pattern — custom annotation, single @Around aspect, independent persistence — is directly applicable to logging, security enforcement, rate limiting, caching, retry logic, and any other concern that belongs to the infrastructure rather than the domain.