← Kursa Dön
📄 Text · 15 min

Custom Annotation ile AOP

Bir trafik levhası düşünün. "Azami Hız 50 km/h" levhası yolun başına konulur ve o yolda geçen tüm araçlara uygulanır. Levhanın kendisi hiçbir şey yapmaz — sadece bir işarettir. Gerçek kontrolü trafik radarı yapar. Ama radarın neyi kontrol edeceğini levha belirler.

Spring AOP'deki custom annotation'lar tam olarak bu trafik levhasıdır. Annotation kendisi hiçbir mantık içermez — sadece "bu metoda şu davranışı uygula" diye işaret koyar. Gerçek mantığı bir Aspect sınıfı (radar) yürütür.

Bu yaklaşım, AOP'nin en temiz ve declarative kullanımıdır:

  • execution(* com.example.service.*.*(..)) gibi kırılgan pattern'ler yerine

  • @Loggable, @Timed, @Auditable gibi anlamlı annotation'lar kullanırsınız


Neden Custom Annotation?

execution() Pointcut'ın Sorunları

// ❌ Paket yolu değişirse kırılır
@Around("execution(* com.example.service.*.*(..))")
public Object logServiceCalls(ProceedingJoinPoint jp) throws Throwable { ... }

// ❌ Yeni bir service paketi eklenirse güncellenmesi gerekir
@Around("execution(* com.example.service..*.*(..)) || " +
        "execution(* com.example.payment.service..*.*(..))")
public Object logAllServices(ProceedingJoinPoint jp) throws Throwable { ... }

Sorunlar:

  • Kırılgan: Paket yapısı değişirse pointcut çalışmaz

  • Belirsiz: Pointcut'a bakarak hangi metotların etkilendiğini anlamak zor

  • Ya hep ya hiç: Bir paketteki TÜM metotlara uygulanır — belirli metotları seçemezsiniz

  • Okunabilirlik: Uzun regex-like ifadeler kodu karmaşıklaştırır

@annotation() Pointcut'ın Avantajları

// ✅ Annotation ile — net, açık, kırılmaz
@Around("@annotation(loggable)")
public Object logAnnotated(ProceedingJoinPoint jp, Loggable loggable) throws Throwable { ... }

Avantajlar:

  • Açık niyet (Explicit intent): Metotun üzerinde annotation'ı gören herkes davranışı anlar

  • Seçici: Sadece istediğiniz metotlara uygularsınız

  • Refactoring-safe: Paket yapısı değişse bile annotation yerinde kalır

  • Parametrik: Annotation'a parametreler ekleyerek davranışı özelleştirebilirsiniz

  • IDE desteği: "Find Usages" ile annotation'ın nerelerde kullanıldığını görürsünüz


Custom Annotation Oluşturma — Temel Bilgiler

Bir annotation tanımlamak için @interface keyword'ü kullanılır:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
    String value() default "";
}

Bu tanımda üç meta-annotation kritik öneme sahiptir:

@Target — Nereye Konulabilir?

@Target, annotation'ın nereye uygulanabileceğini belirler:

@Target(ElementType.METHOD)           // Sadece metotlara
@Target(ElementType.TYPE)             // Sadece sınıf/interface'e
@Target(ElementType.FIELD)            // Sadece alanlara
@Target(ElementType.PARAMETER)        // Sadece parametrelere
@Target({ElementType.METHOD, ElementType.TYPE})  // Hem metot hem sınıf

AOP için en yaygın kullanım:

  • METHOD — Belirli metotlara aspect uygulama

  • TYPE — Sınıftaki tüm metotlara aspect uygulama

  • METHOD, TYPE — Her iki kullanım da mümkün

@Retention — Ne Kadar Süre Yaşar?

@Retention, annotation'ın hangi aşamada erişilebilir olduğunu belirler:

@Retention(RetentionPolicy.SOURCE)    // Derleme sırasında silinir (Lombok gibi)
@Retention(RetentionPolicy.CLASS)     // .class dosyasında var, runtime'da erişilemez
@Retention(RetentionPolicy.RUNTIME)   // Runtime'da erişilebilir (AOP İÇİN ZORUNLU)

⚠️ Dikkat: AOP annotation'ları için `RUNTIME` zorunludur. Spring, runtime'da reflection ile annotation'ları okur. SOURCE veya CLASS kullanırsanız AOP çalışmaz!

@Documented

Annotation'ın Javadoc'a dahil edilmesini sağlar. Opsiyoneldir ama best practice'tir.


Örnek 1: @Loggable — Metot Loglama

En temel custom annotation: metot giriş-çıkışını loglar.

// ─── Annotation Tanımı ───
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Loggable {
    /**
     * Log mesajında gösterilecek etiket.
     * Boş bırakılırsa metot adı kullanılır.
     */
    String value() default "";

    /**
     * Log seviyesi. Varsayılan INFO.
     */
    LogLevel level() default LogLevel.INFO;

    /**
     * Parametreleri logla?
     * Hassas veriler (şifre gibi) için false yapın.
     */
    boolean logArgs() default true;

    /**
     * Dönüş değerini logla?
     * Büyük listeler için false yapın.
     */
    boolean logResult() default true;

    enum LogLevel { TRACE, DEBUG, INFO, WARN }
}

// ─── Aspect ───
@Aspect
@Component
@Slf4j
public class LoggableAspect {

    @Around("@annotation(loggable)")
    public Object logMethod(ProceedingJoinPoint joinPoint, Loggable loggable) throws Throwable {
        String label = loggable.value().isEmpty()
            ? joinPoint.getSignature().toShortString()
            : loggable.value();

        // Giriş logu
        if (loggable.logArgs()) {
            logAtLevel(loggable.level(), "→ {} called with args: {}",
                label, summarize(joinPoint.getArgs()));
        } else {
            logAtLevel(loggable.level(), "→ {} called", label);
        }

        long start = System.nanoTime();

        try {
            Object result = joinPoint.proceed();
            long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);

            // Çıkış logu
            if (loggable.logResult()) {
                logAtLevel(loggable.level(), "← {} returned: {} ({}ms)",
                    label, summarize(result), elapsedMs);
            } else {
                logAtLevel(loggable.level(), "← {} completed ({}ms)", label, elapsedMs);
            }

            return result;

        } catch (Throwable ex) {
            long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
            log.error("✕ {} failed after {}ms: {}", label, elapsedMs, ex.getMessage());
            throw ex;
        }
    }

    // Sınıf düzeyinde annotation desteği
    @Around("@within(loggable)")
    public Object logClassMethods(ProceedingJoinPoint joinPoint,
                                    Loggable loggable) throws Throwable {
        // Metot düzeyinde annotation varsa onu kullan (öncelik)
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Loggable methodAnnotation = method.getAnnotation(Loggable.class);
        if (methodAnnotation != null) {
            return joinPoint.proceed(); // Metot-level aspect zaten çalışacak
        }
        return logMethod(joinPoint, loggable);
    }

    private void logAtLevel(Loggable.LogLevel level, String format, Object... args) {
        switch (level) {
            case TRACE -> log.trace(format, args);
            case DEBUG -> log.debug(format, args);
            case INFO  -> log.info(format, args);
            case WARN  -> log.warn(format, args);
        }
    }

    private String summarize(Object obj) {
        if (obj == null) return "null";
        if (obj instanceof Collection<?> c) return "[" + c.size() + " items]";
        if (obj.getClass().isArray()) return "[" + Array.getLength(obj) + " items]";
        String str = obj.toString();
        return str.length() > 200 ? str.substring(0, 200) + "..." : str;
    }

    private String summarize(Object[] args) {
        if (args == null || args.length == 0) return "[]";
        return Arrays.stream(args)
            .map(this::summarize)
            .collect(Collectors.joining(", ", "[", "]"));
    }
}

// ─── Kullanım ───
@Service
public class OrderService {

    @Loggable("Create Order")
    public OrderResponse createOrder(CreateOrderRequest request) {
        // İş mantığı...
    }

    @Loggable(value = "Process Payment", logArgs = false)  // Hassas veri
    public PaymentResult processPayment(PaymentDetails details) {
        // Kart bilgilerini loglamayız
    }

    @Loggable(level = Loggable.LogLevel.DEBUG, logResult = false)  // Büyük liste
    public List<OrderResponse> searchOrders(OrderSearchCriteria criteria) {
        // Binlerce kayıt dönebilir, loglamayız
    }
}

// Sınıf düzeyinde — tüm metotları logla
@Service
@Loggable(level = Loggable.LogLevel.DEBUG)
public class AuditService {
    // Tüm metotlar DEBUG seviyesinde loglanır
}

Örnek 2: @Timed — Performance Monitoring

// ─── Annotation ───
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Timed {
    String metricName() default "";
    long warnThresholdMs() default 500;
    long errorThresholdMs() default 3000;
    String[] tags() default {};
}

// ─── Aspect ───
@Aspect
@Component
@Slf4j
public class TimedAspect {

    @Around("@annotation(timed)")
    public Object measureTime(ProceedingJoinPoint joinPoint, Timed timed) throws Throwable {
        String metric = timed.metricName().isEmpty()
            ? joinPoint.getSignature().getName()
            : timed.metricName();

        long start = System.nanoTime();

        try {
            Object result = joinPoint.proceed();
            long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);

            if (elapsedMs >= timed.errorThresholdMs()) {
                log.error("🔴 CRITICAL: {} took {}ms (threshold: {}ms)",
                    metric, elapsedMs, timed.errorThresholdMs());
            } else if (elapsedMs >= timed.warnThresholdMs()) {
                log.warn("🟡 SLOW: {} took {}ms (threshold: {}ms)",
                    metric, elapsedMs, timed.warnThresholdMs());
            } else {
                log.debug("🟢 {} completed in {}ms", metric, elapsedMs);
            }

            return result;

        } catch (Throwable ex) {
            long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
            log.error("💥 {} failed after {}ms: {}", metric, elapsedMs, ex.getMessage());
            throw ex;
        }
    }
}

// ─── Kullanım ───
@Service
public class ReportService {

    @Timed(metricName = "generate_monthly_report", warnThresholdMs = 2000, errorThresholdMs = 10000)
    public Report generateMonthlyReport(int year, int month) {
        // Uzun süren işlem...
    }
}

Örnek 3: @Auditable — Denetim Kaydı

Kritik iş operasyonlarını denetim amaçlı kaydetmek:

// ─── Annotation ───
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
    String action();
    String entity() default "";
}

// ─── Aspect ───
@Aspect
@Component
@Slf4j
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;
    private final SecurityContextHolder securityContext;

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

    @AfterReturning(pointcut = "@annotation(auditable)", returning = "result")
    public void audit(JoinPoint joinPoint, Auditable auditable, Object result) {
        String username = getCurrentUsername();
        String action = auditable.action();
        String entity = auditable.entity().isEmpty()
            ? inferEntityFromMethod(joinPoint)
            : auditable.entity();

        AuditLog auditLog = AuditLog.builder()
            .username(username)
            .action(action)
            .entity(entity)
            .details(buildDetails(joinPoint, result))
            .timestamp(Instant.now())
            .ipAddress(getClientIp())
            .build();

        // Async kaydet — iş mantığını yavaşlatma
        CompletableFuture.runAsync(() -> auditLogRepository.save(auditLog));

        log.info("AUDIT: {} performed '{}' on {}", username, action, entity);
    }

    private String buildDetails(JoinPoint joinPoint, Object result) {
        Map<String, Object> details = new LinkedHashMap<>();
        details.put("method", joinPoint.getSignature().toShortString());
        details.put("args", Arrays.toString(joinPoint.getArgs()));
        if (result != null) {
            details.put("resultType", result.getClass().getSimpleName());
        }
        return details.toString();
    }

    private String getCurrentUsername() {
        var auth = SecurityContextHolder.getContext().getAuthentication();
        return auth != null ? auth.getName() : "anonymous";
    }

    private String getClientIp() {
        var requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes instanceof ServletRequestAttributes sra) {
            return sra.getRequest().getRemoteAddr();
        }
        return "unknown";
    }

    private String inferEntityFromMethod(JoinPoint joinPoint) {
        String className = joinPoint.getTarget().getClass().getSimpleName();
        return className.replace("Service", "").replace("Controller", "");
    }
}

// ─── Kullanım ───
@Service
public class UserService {

    @Auditable(action = "CREATE", entity = "User")
    public UserResponse createUser(CreateUserRequest request) { ... }

    @Auditable(action = "UPDATE", entity = "User")
    public UserResponse updateUser(Long id, UpdateUserRequest request) { ... }

    @Auditable(action = "DELETE", entity = "User")
    public void deleteUser(Long id) { ... }

    @Auditable(action = "CHANGE_PASSWORD", entity = "User")
    public void changePassword(Long id, ChangePasswordRequest request) { ... }
}

Audit log çıktısı:

AUDIT: admin performed 'DELETE' on User
AUDIT: john.doe performed 'CHANGE_PASSWORD' on User

Örnek 4: @RequireRole — Yetkilendirme

// ─── Annotation ───
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    String[] value();  // Gerekli roller
    boolean all() default false;  // true = hepsi gerekli, false = herhangi biri yeterli
}

// ─── Aspect ───
@Aspect
@Component
public class SecurityAspect {

    @Before("@annotation(requireRole)")
    public void checkRole(JoinPoint joinPoint, RequireRole requireRole) {
        var auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth == null || !auth.isAuthenticated()) {
            throw new UnauthorizedException("Kimlik doğrulama gerekli");
        }

        Set<String> userRoles = auth.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .collect(Collectors.toSet());

        String[] requiredRoles = requireRole.value();

        boolean hasAccess;
        if (requireRole.all()) {
            // Tüm roller gerekli
            hasAccess = userRoles.containsAll(Arrays.asList(requiredRoles));
        } else {
            // Herhangi bir rol yeterli
            hasAccess = Arrays.stream(requiredRoles).anyMatch(userRoles::contains);
        }

        if (!hasAccess) {
            throw new ForbiddenException(
                String.format("Gerekli roller: %s. Mevcut: %s",
                    Arrays.toString(requiredRoles), userRoles));
        }
    }
}

// ─── Kullanım ───
@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @GetMapping("/users")
    @RequireRole("ROLE_ADMIN")
    public List<UserResponse> getAllUsers() { ... }

    @DeleteMapping("/users/{id}")
    @RequireRole(value = {"ROLE_ADMIN", "ROLE_SUPER_ADMIN"}, all = false)
    public void deleteUser(@PathVariable Long id) { ... }

    @PostMapping("/system/reset")
    @RequireRole(value = {"ROLE_ADMIN", "ROLE_SUPER_ADMIN"}, all = true)
    public void resetSystem() { ... }
}

Örnek 5: @Cacheable (Basitleştirilmiş)

// ─── Annotation ───
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SimpleCache {
    String name();
    long ttlSeconds() default 300;  // 5 dakika
}

// ─── Aspect ───
@Aspect
@Component
@Slf4j
public class SimpleCacheAspect {

    private final Map<String, CacheEntry> cache = new ConcurrentHashMap<>();

    @Around("@annotation(simpleCache)")
    public Object checkCache(ProceedingJoinPoint joinPoint,
                               SimpleCache simpleCache) throws Throwable {
        String key = simpleCache.name() + ":" + Arrays.toString(joinPoint.getArgs());

        CacheEntry entry = cache.get(key);
        if (entry != null && entry.isValid()) {
            log.debug("Cache HIT: {}", key);
            return entry.value();
        }

        log.debug("Cache MISS: {}", key);
        Object result = joinPoint.proceed();

        cache.put(key, new CacheEntry(result,
            Instant.now().plusSeconds(simpleCache.ttlSeconds())));

        return result;
    }

    private record CacheEntry(Object value, Instant expiresAt) {
        boolean isValid() { return Instant.now().isBefore(expiresAt); }
    }
}

// ─── Kullanım ───
@Service
public class ProductService {

    @SimpleCache(name = "products", ttlSeconds = 600)
    public ProductResponse findById(Long id) {
        return productRepository.findById(id)
            .map(productMapper::toResponse)
            .orElseThrow(() -> new ResourceNotFoundException("Product", "id", id));
    }
}

Annotation Parametrelerine Erişim

Aspect metotlarında annotation parametrelerine erişmenin iki yolu vardır:

Yol 1: Parametre Binding (Önerilen)

// Annotation parametresini doğrudan metot parametresi olarak al
@Around("@annotation(loggable)")  // küçük harf = parametre adı
public Object log(ProceedingJoinPoint jp, Loggable loggable) throws Throwable {
    String label = loggable.value();
    // ...
}

Yol 2: Reflection ile Okuma

@Around("@annotation(com.example.annotation.Loggable)")  // full qualified name
public Object log(ProceedingJoinPoint jp) throws Throwable {
    MethodSignature signature = (MethodSignature) jp.getSignature();
    Loggable loggable = signature.getMethod().getAnnotation(Loggable.class);
    String label = loggable.value();
    // ...
}

💡 İpucu: Parametre binding (Yol 1) daha temiz ve type-safe'tir. Reflection (Yol 2) sadece annotation'a dinamik erişim gerektiğinde kullanın.


Birden Fazla Custom Annotation Birleştirme

Bir metoda birden fazla annotation ekleyebilirsiniz:

@Service
public class PaymentService {

    @Loggable("Process Payment")
    @Timed(metricName = "payment_processing", warnThresholdMs = 1000)
    @Auditable(action = "PAYMENT", entity = "Transaction")
    @Retryable(maxAttempts = 3, retryOn = TransientException.class)
    public PaymentResult processPayment(PaymentRequest request) {
        return paymentGateway.charge(request);
    }
}

Çalışma sırası @Order ile belirlenir:

@Aspect @Component @Order(1)  // İlk çalışır — en dışta
public class RetryAspect { }

@Aspect @Component @Order(2)
public class AuditAspect { }

@Aspect @Component @Order(3)  // Son çalışır — en içte
public class LoggableAspect { }
RetryAspect.before
  → AuditAspect.before
    → LoggableAspect.before
      → paymentService.processPayment()  ← Hedef metot
    ← LoggableAspect.after
  ← AuditAspect.after
← RetryAspect.after

Yaygın Hatalar

1. ❌ @Retention(RUNTIME) Unutmak

// ❌ YANLIŞ — varsayılan CLASS, AOP çalışmaz!
@Target(ElementType.METHOD)
public @interface Loggable { }

// ✅ DOĞRU
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)  // ZORUNLU
public @interface Loggable { }

2. ❌ Annotation İsmi ile Parametre İsmi Uyuşmaması

// ❌ YANLIŞ — "loggable" parametresi yok
@Around("@annotation(loggable)")
public Object log(ProceedingJoinPoint jp, Loggable myAnnotation) { }
//                                         ↑ parametre adı "myAnnotation"

// ✅ DOĞRU — isimler eşleşmeli
@Around("@annotation(loggable)")
public Object log(ProceedingJoinPoint jp, Loggable loggable) { }
//                                         ↑ "loggable" ile eşleşir

3. ❌ Private Metotlara Annotation Koymak

@Service
public class UserService {

    @Loggable  // ❌ AOP ÇALIŞMAZ — Spring AOP proxy-based, private metotlara ulaşamaz
    private User findUserInternal(Long id) { ... }

    @Loggable  // ✅ ÇALIŞIR — public metot
    public UserResponse findById(Long id) { ... }
}

4. ❌ Aynı Sınıf İçinden Çağrı (Self-Invocation)

@Service
public class UserService {

    @Loggable
    public UserResponse findById(Long id) { ... }

    public UserResponse getActiveUser(Long id) {
        return this.findById(id);  // ❌ @Loggable ÇALIŞMAZ!
        // this → gerçek nesne, proxy değil
    }
}

Annotation mi, execution() mı? Karar Rehberi

Kriterexecution()@annotation
GranülaritePaket/sınıf düzeyindeMetot düzeyinde
AçıklıkGizli — kodu okuyan anlamayabilirAçık — metotta görünür
BakımPaket değişince kırılırRefactoring-safe
Tercih edinAltyapı (tüm repository, tüm controller)İş mantığı (belirli metotlar)

Önerilen yaklaşım:

  • Altyapı concern'leri (tüm controller'ları loglama): execution() veya within()

  • İş mantığı concern'leri (belirli metotları audit etme): @annotation()

  • Hibrit: Named pointcut'larla ikisini birleştirin


Özet

  • Custom annotation + AOP, en temiz ve declarative cross-cutting concern yönetimidir

  • @Target(METHOD) + @Retention(RUNTIME) + @interface ile annotation tanımlanır

  • @annotation(paramName) pointcut'ı ile annotation'a sahip metotlar hedeflenir

  • Annotation parametreleri, advice metotlarında doğrudan erişilebilir (parametre binding)

  • @Loggable, @Timed, @Auditable, @Retryable — yaygın custom annotation örnekleri

  • `@Retention(RUNTIME)` zorunludur — aksi halde AOP çalışmaz

  • Private metotlara ve self-invocation'da annotation-based AOP çalışmaz (proxy kısıtlaması)

  • Birden fazla annotation birleştirilebilir, sıralama @Order ile belirlenir