Spring Boot'ta Custom Annotation Yazma
Spring Boot'ta Custom Annotation Yazma
Spring Boot'un büyüsü annotation'larda gizli. @RestController yazıyorsun, sınıf bir REST controller oluyor. @Transactional ekliyorsun, metot transactional hale geliyor. Peki bu annotation'lar nasıl çalışıyor? Daha önemlisi — kendi annotation'ını yazıp Spring'in gücünü kendi ihtiyaçlarına göre kullanabilir misin?
Cevap: Kesinlikle evet. Ve bunu öğrendiğinde, tekrar eden boilerplate kodu tek bir @ işaretiyle ortadan kaldırabilirsin. Bu yazıda sıfırdan kendi annotation'larını yazacak, AOP (Aspect-Oriented Programming) ile bağlayacak ve production-ready örneklerle pekiştireceksin.
Annotation Nedir ve Nasıl Çalışır?
Annotation'ları (açıklama/not düşme) kodun üzerine yapıştırdığın etiketler gibi düşün. Bir kargonun üzerine "kırılacak" etiketi yapıştırırsın — kargocu bu etiketi görür ve pakete dikkatli davranır. Annotation da aynı mantıkla çalışır: kodun üzerine bir etiket koyarsın, bir mekanizma (Spring, derleyici, framework) bu etiketi okur ve ona göre davranır.
Java'da üç farklı zamanda annotation işlenebilir:
Derleme zamanı (compile-time) —
@Override,@Deprecatedgibi. Derleyici bunları kontrol eder.Sınıf yükleme zamanı (class-loading) — Bytecode manipulation araçları kullanır.
Çalışma zamanı (runtime) — Spring'in yaptığı budur. Reflection ile annotation'ları okur ve işlem yapar.
Biz runtime annotation'larla çalışacağız çünkü Spring ekosistemi tamamen bunun üzerine kurulu.
İlk Custom Annotation: @LogExecutionTime
En klasik ve en kullanışlı örnekle başlayalım. Bir metoda @LogExecutionTime annotation'ı koyduğunda, o metodun ne kadar sürede çalıştığını otomatik olarak loglamak istiyorsun:
Adım 1: Annotation'ı Tanımla
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD) // sadece metotlara uygulanabilir
@Retention(RetentionPolicy.RUNTIME) // çalışma zamanında okunabilir
public @interface LogExecutionTime {
String value() default ""; // opsiyonel açıklama
}Burada üç meta-annotation (annotation'ın annotation'ı) var:
`@Target` — Bu annotation nereye konulabilir?
METHODdiyoruz, yani sadece metotlara.TYPEdesek sınıflara,FIELDdesek alanlara da uygulanabilirdi. Birden fazla hedef de verebilirsin:@Target({ElementType.METHOD, ElementType.TYPE}).`@Retention` — Ne zamana kadar hayatta kalsın?
RUNTIMEdiyoruz çünkü Spring'in çalışma zamanında bunu okuması gerekiyor.SOURCEdesek sadece kaynak kodda kalır (Lombok gibi),CLASSdesek .class dosyasında kalır ama reflection ile okunamaz.`@interface` — Java'da annotation tanımlamak için
@interfaceanahtar kelimesi kullanılır. Normalinterface'ten farklıdır; metotları parametre gibi davranır.
Adım 2: Aspect ile İşleme
Annotation'ı tanımladık ama henüz hiçbir şey yapmıyor — sadece bir etiket. Şimdi bu etiketi gören ve iş yapan mekanizmayı yazacağız. Spring AOP (Aspect-Oriented Programming) kullanacağız:
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Aspect // bu sınıf bir aspect (kesişen ilgi alanı)
@Component // Spring bean olarak kaydet
public class LogExecutionTimeAspect {
private static final Logger log = LoggerFactory.getLogger(LogExecutionTimeAspect.class);
// @LogExecutionTime annotation'ı olan her metodu yakala
@Around("@annotation(logExecutionTime)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint,
LogExecutionTime logExecutionTime) throws Throwable {
// Metot bilgilerini al
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getDeclaringType().getSimpleName() + "." + signature.getName();
String description = logExecutionTime.value();
// Zamanlayıcıyı başlat
long start = System.currentTimeMillis();
try {
// Orijinal metodu çalıştır
Object result = joinPoint.proceed();
// Süreyi hesapla ve logla
long duration = System.currentTimeMillis() - start;
log.info("⏱ {} [{}] tamamlandı: {}ms",
methodName, description.isEmpty() ? "no-desc" : description, duration);
return result;
} catch (Throwable ex) {
long duration = System.currentTimeMillis() - start;
log.error("❌ {} [{}] hata ile sonlandı ({}ms): {}",
methodName, description, duration, ex.getMessage());
throw ex; // hatayı tekrar fırlat
}
}
}AOP Terminolojisi hızlı özet:
Aspect: Kesişen ilgi alanını (cross-cutting concern) kapsayan sınıf. Loglama, güvenlik, caching gibi birden fazla yerde tekrarlanan mantık.
Join Point: Aspect'in devreye girdiği nokta. Bizim örnekte: annotation'lı metodun çağrıldığı an.
Advice: Aspect'in çalıştırdığı kod.
@Around,@Before,@Aftergibi türleri var.@Arounden güçlüsü — metodun öncesinde, sonrasında ve hata durumunda müdahale edebilirsin.Pointcut: Hangi join point'lerin yakalanacağını belirleyen ifade.
@annotation(logExecutionTime)diyoruz — yani bu annotation'ı taşıyan her metot.
Adım 3: Kullanım
@Service
public class ProductService {
@LogExecutionTime("Ürün listesi çekme")
public List<Product> getAllProducts() {
// veritabanından ürünleri çek
return productRepository.findAll();
}
@LogExecutionTime("Ürün arama")
public List<Product> searchProducts(String keyword) {
// arama işlemi
return productRepository.findByNameContaining(keyword);
}
}Çıktı:
⏱ ProductService.getAllProducts [Ürün listesi çekme] tamamlandı: 45ms
⏱ ProductService.searchProducts [Ürün arama] tamamlandı: 123msTek bir annotation ile her metodun süresini logluyoruz. Boilerplate kod yok, iş mantığı temiz kalıyor.
Bağımlılık Gereksinimi
pom.xml'e Spring AOP bağımlılığını eklemeyi unutma:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>Pratik Örnek: @RateLimit Annotation'ı
Daha ileri bir örneğe geçelim. API endpoint'lerine rate limiting (hız sınırlama) ekleyen bir annotation yazalım. Her IP adresi belirli bir sürede belirli sayıda istek atabilsin:
Annotation Tanımı
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int maxRequests() default 10; // max istek sayısı
long duration() default 60; // süre miktarı
TimeUnit timeUnit() default TimeUnit.SECONDS; // süre birimi
String message() default "Çok fazla istek gönderdiniz. Lütfen bekleyin.";
}Aspect İmplementasyonu
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
@Aspect
@Component
public class RateLimitAspect {
// IP -> istek zamanlarını tutan map
// Production'da Redis kullanmalısın — bu örnek tek instance için
private final Map<String, CopyOnWriteArrayList<Long>> requestLog = new ConcurrentHashMap<>();
@Around("@annotation(rateLimit)")
public Object enforce(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
// İstek yapan IP adresini al
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.currentRequestAttributes()).getRequest();
String clientIp = request.getRemoteAddr();
// Zaman penceresini hesapla
long now = System.currentTimeMillis();
long windowMs = rateLimit.timeUnit().toMillis(rateLimit.duration());
long windowStart = now - windowMs;
// Bu IP'nin isteklerini al, pencere dışındakileri temizle
CopyOnWriteArrayList<Long> timestamps = requestLog
.computeIfAbsent(clientIp, k -> new CopyOnWriteArrayList<>());
timestamps.removeIf(t -> t < windowStart); // eski istekleri temizle
// Limit kontrolü
if (timestamps.size() >= rateLimit.maxRequests()) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
rateLimit.message()
);
}
// İsteği kaydet ve metodu çalıştır
timestamps.add(now);
return joinPoint.proceed();
}
}Kullanım
@RestController
@RequestMapping("/api")
public class AuthController {
// Dakikada en fazla 5 giriş denemesi
@RateLimit(maxRequests = 5, duration = 1, timeUnit = TimeUnit.MINUTES,
message = "Çok fazla giriş denemesi. 1 dakika bekleyin.")
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginRequest request) {
// giriş mantığı
return ResponseEntity.ok("Giriş başarılı");
}
// Saatte en fazla 100 arama
@RateLimit(maxRequests = 100, duration = 1, timeUnit = TimeUnit.HOURS)
@GetMapping("/search")
public ResponseEntity<List<Product>> search(@RequestParam String q) {
// arama mantığı
return ResponseEntity.ok(productService.search(q));
}
}Dikkat et: Controller'daki iş mantığında rate limiting ile ilgili tek bir satır yok. Tüm kontrol annotation ve aspect tarafında. Bu, Separation of Concerns (ilgilerin ayrılması) prensibinin güzel bir uygulaması.
Validation Annotation'ı: @ValidTCKN
Java Bean Validation (JSR 380) ile entegre çalışan custom annotation da yazabilirsin. TC Kimlik Numarası doğrulayan bir annotation yapalım:
Annotation Tanımı
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = TCKNValidator.class) // doğrulayıcı sınıf
@Documented
public @interface ValidTCKN {
String message() default "Geçersiz TC Kimlik Numarası";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}Bean Validation annotation'ları message, groups ve payload alanlarını zorunlu olarak içermelidir. Bu, JSR 380 spesifikasyonunun gereksinimidir.
Validator Sınıfı
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class TCKNValidator implements ConstraintValidator<ValidTCKN, String> {
@Override
public void initialize(ValidTCKN annotation) {
// Başlangıç konfigürasyonu gerekirse burada yapılır
}
@Override
public boolean isValid(String tckn, ConstraintValidatorContext context) {
// null kontrolü — @NotNull ile birlikte kullanılmalı
if (tckn == null || tckn.isBlank()) {
return false;
}
// 11 haneli olmalı
if (tckn.length() != 11) return false;
// Sadece rakam olmalı
if (!tckn.matches("\\d{11}")) return false;
// İlk hane 0 olamaz
if (tckn.charAt(0) == '0') return false;
// Algoritmik doğrulama
int[] digits = tckn.chars().map(c -> c - '0').toArray();
// 10. hane kontrolü
int oddSum = digits[0] + digits[2] + digits[4] + digits[6] + digits[8];
int evenSum = digits[1] + digits[3] + digits[5] + digits[7];
int tenthDigit = ((oddSum * 7) - evenSum) % 10;
if (tenthDigit < 0) tenthDigit += 10;
if (digits[9] != tenthDigit) return false;
// 11. hane kontrolü
int sum = 0;
for (int i = 0; i < 10; i++) sum += digits[i];
if (digits[10] != sum % 10) return false;
return true;
}
}Kullanım
// DTO'da kullanım
public record UserRegistrationRequest(
@NotBlank(message = "İsim boş olamaz")
String name,
@ValidTCKN // kendi annotation'ımız!
String tckn,
@Email(message = "Geçerli bir email girin")
String email
) {}
// Controller'da kullanım
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping("/register")
public ResponseEntity<String> register(@Valid @RequestBody UserRegistrationRequest request) {
// @Valid annotation'ı sayesinde validasyon otomatik çalışır
// Geçersiz TCKN varsa 400 Bad Request döner
return ResponseEntity.ok("Kayıt başarılı: " + request.name());
}
}Spring Boot'un @Valid mekanizması otomatik olarak @ValidTCKN annotation'ını bulur, TCKNValidator'ı çalıştırır ve geçersizse MethodArgumentNotValidException fırlatır. Senin controller kodun validasyon detaylarıyla hiç uğraşmaz.
Meta-Annotation: Annotation'ları Birleştirme
Spring'te bir annotation başka annotation'ları taşıyabilir. Buna meta-annotation denir ve tekrar eden annotation gruplarını sadeleştirir:
// Her seferinde bunları yazmak yerine:
@RestController
@RequestMapping("/api/v1")
@CrossOrigin(origins = "*")
@Validated
// Hepsini tek bir annotation'da birleştir:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1")
@CrossOrigin(origins = "*")
@Validated
public @interface ApiV1Controller {
// Parametre gerekmiyorsa boş bırakılabilir
}
// Artık controller'larda tek satır yeterli:
@ApiV1Controller
public class ProductController {
// otomatik olarak @RestController, /api/v1 prefix,
// CORS açık ve validation aktif
}Bu pattern özellikle mikroservis projelerinde işe yarar. Tüm controller'ların aynı temel konfigürasyona sahip olmasını garantilersin ve birini değiştirmek istediğinde tek yerden değişiklik yaparsın.
Conditional Bean Registration: @ConditionalOnProperty ile Custom Annotation
Bazı durumlarda bir özelliğin sadece belirli koşullarda aktif olmasını istersin:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
@ConditionalOnProperty(name = "feature.cache.enabled", havingValue = "true")
public @interface CacheableService {
}
// Bu servis sadece application.yml'de feature.cache.enabled=true ise aktif olur
@CacheableService
public class RedisCacheService implements CacheService {
// Redis implementasyonu
}application.yml'de feature.cache.enabled: false ise bu bean hiç oluşturulmaz. Feature flag'leri bu şekilde annotation bazlı yönetebilirsin.
Yaygın Hatalar ve Tuzaklar
1. Retention Policy'yi RUNTIME Yapmayı Unutmak
// ❌ Varsayılan retention CLASS'tır — Spring çalışma zamanında göremez!
@Target(ElementType.METHOD)
public @interface MyAnnotation { }
// ✅ RUNTIME olmalı
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation { }@Retention belirtmezsen varsayılan CLASS'tır ve Spring reflection ile bu annotation'ı göremez. Her custom annotation'da @Retention(RetentionPolicy.RUNTIME) olduğundan emin ol.
2. AOP'nin Proxy Sınırlaması
Spring AOP, proxy tabanlı çalışır. Bu şu demek: aynı sınıf içindeki metot çağrılarında annotation çalışmaz:
@Service
public class OrderService {
@LogExecutionTime("Sipariş oluşturma")
public Order createOrder(OrderRequest request) {
// ...
validateOrder(request); // ❌ Bu çağrı proxy'den GEÇMEZ!
// ...
}
@LogExecutionTime("Sipariş doğrulama")
public void validateOrder(OrderRequest request) {
// Bu annotation çalışMAZ çünkü aynı sınıf içinden çağrıldı
}
}createOrder dışarıdan çağrıldığında proxy üzerinden geçer ve annotation çalışır. Ama createOrder içinden validateOrder çağrıldığında, bu çağrı doğrudan nesne üzerinden yapılır — proxy bypass edilir. Çözüm: validateOrder'ı başka bir servise taşı veya self-injection kullan:
@Service
public class OrderService {
@Lazy
@Autowired
private OrderService self; // kendine injection — proxy referansı
@LogExecutionTime("Sipariş oluşturma")
public Order createOrder(OrderRequest request) {
self.validateOrder(request); // ✅ Proxy üzerinden geçer!
// ...
}
@LogExecutionTime("Sipariş doğrulama")
public void validateOrder(OrderRequest request) {
// Artık annotation çalışır
}
}3. Private Metotlarda AOP Çalışmaz
// ❌ Spring AOP private metotları yakalayamaz
@LogExecutionTime
private void helperMethod() { }
// ✅ En az package-private (default) veya public olmalı
@LogExecutionTime
public void helperMethod() { }Spring AOP, JDK dynamic proxy veya CGLIB proxy kullanır. Her iki durumda da private metotlar proxy tarafından override edilemez, dolayısıyla aspect çalışmaz.
4. Annotation Parametrelerinde Sadece Derleme Zamanı Sabitleri Kullanabilirsin
// ❌ Dinamik değer veremezsin
@RateLimit(maxRequests = getMaxFromConfig()) // DERLEME HATASI!
// ✅ Sadece sabitler, enum'lar, string literal'lar, Class referansları
@RateLimit(maxRequests = 10) // ✓Annotation parametreleri derleme zamanında bilinmelidir. Dinamik konfigürasyon gerekiyorsa, annotation'da bir anahtar belirt ve aspect içinde bu anahtarla konfigürasyonu oku:
@RateLimit(configKey = "login.rate-limit") // anahtar olarak ver
// Aspect içinde: environment.getProperty(rateLimit.configKey())Best Practices
Tek Sorumluluk. Her annotation tek bir iş yapsın.
@LogAndCacheAndValidategibi hepsini birleştiren annotation'lar yazmak cazip ama bakım kabusu olur. Bunun yerine@Log,@Cache,@Validateayrı annotation'lar yaz ve birlikte kullan.İsimlendirme açık olsun.
@MyAnnotationdeğil,@LogExecutionTime,@RateLimit,@ValidTCKNgibi ne yaptığı isimden anlaşılan annotation'lar yaz.Varsayılan değerler akıllı olsun. Parametrelerin
defaultdeğerleri makul olmalı — kullanıcı hiçbir parametre vermeden annotation'ı kullanabilmeli.@RateLimithiç parametre olmadan bile 10 istek/60 saniye gibi mantıklı bir default ile çalışmalı.Dokümantasyon yaz. Custom annotation'ın üzerine Javadoc yaz. Ne yaptığını, parametrelerini ve kullanım örneğini belirt. 3 ay sonra bunu okuyan kişi (muhtemelen sen) teşekkür edecek.
Test yaz. Aspect'lerin doğru çalıştığını entegrasyon testleriyle doğrula.
@SpringBootTestile gerçek context'te annotation'ın devreye girdiğini test et.`@Inherited` dikkatli kullan.
@Inheritedmeta-annotation'ı eklersen, alt sınıflar da annotation'ı taşır. Bu bazen istenen bir davranış, bazen istenmeyen. Bilinçli karar ver.Production'da performans düşün. Her AOP çağrısı reflection ve proxy overhead getirir. Çok sık çağrılan hot-path metotlarında (örneğin saniyede binlerce kez çağrılan utility metotları) AOP dikkatli kullanılmalı.
Sonuç
Custom annotation yazma, Spring Boot'un en güçlü özelliklerinden biri. Bu yazıda öğrendiklerini özetleyelim:
`@interface` ile annotation tanımla, `@Target` ve `@Retention(RUNTIME)` meta-annotation'larını koy
Spring AOP (
@Aspect+@Around) ile annotation'a davranış ekleBean Validation (
ConstraintValidator) ile validation annotation'ı yazMeta-annotation ile annotation'ları birleştirip boilerplate'i azalt
Proxy sınırlamalarını bil — aynı sınıf içi çağrılar ve private metotlar AOP tarafından yakalanamaz
Her annotation'ın tek sorumluluğu olsun, varsayılan değerleri akıllı olsun
Custom annotation yazmak ilk başta karmaşık gelebilir ama öğrendiğinde tekrar eden kodları ortadan kaldırmanın en zarif yollarından biri olduğunu göreceksin. @LogExecutionTime, @RateLimit, @Auditable, @AdminOnly gibi annotation'lar projenin her yerinde kullanılabilir ve iş mantığını temiz tutar. Bir sonraki adım olarak Spring'in kendi annotation'larının kaynak kodunu incelemeni öneririm — @Transactional, @Cacheable gibi annotation'ların nasıl implemente edildiğini görmek, tüm resmi oturtacaktır.
Bu yazıyı beğendiniz mi?
Bültene abone olun ve yeni yazılardan ilk siz haberdar olun. Spam yok, söz.
Bu konuyu derinlemesine öğrenmek ister misin?
Spring Boot: Sıfırdan İleri Seviyeye
İlgili Yazılar
Spring Boot'ta Exception Handling: @ControllerAdvice ile Profesyonel Hata Yönetimi
Spring Boot uygulamalarında hata yönetimini profesyonel seviyeye taşıyın. @ControllerAdvice, @ExceptionHandler, custom e...
Spring Boot Nedir? Neden Spring Boot Kullanmalısınız?
Spring Boot nedir, ne işe yarar? Auto-configuration, starter dependencies, embedded server özellikleri. Java backend gel...
Redis ile Spring Boot Cache Stratejileri
Spring Boot ve Redis ile cache stratejileri: @Cacheable, @CacheEvict, TTL yönetimi, Cache Aside pattern, Write-Through v...