Spring WebSpring Data JPAPostgreSQL DriverLombokEureka Discovery ClientConfig ClientValidationSpring Boot ActuatorSpring for RabbitMQTestcontainersSpring Doc Swagger’da istiyorsak onu da eklemeliyizSpring Initializr’a bunları ekleyeceğiz.
Burda yeni olarak gördüğümüz sadece Testcontainers var.
Integration test için. Gerçek bir PostgreSQL container’ı ayağa kaldırıp testleri o DB üzerinde çalıştırıyoruz.
@SpringBootTest
@Testcontainers
class ProductServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15");
// Test bitti mi? Container otomatik siliniyor.
}
Unit test → DB yok, her şey mock Integration test → Gerçek PostgreSQL, ama geçici container içinde
Burda mockdan kastımız, gerçek bir veritabanı yok, her şey bizim yazdığımız if-else’ler üzerinden yürüyor. Bunu dublör gibi düşünebiliriz. Veritabanını bizim yazdığımız senaryo doğrultusunda taklit eder.
Integration Test ise lokaldeki database’e dokunmadan, bir test db containerı ayağı kaldırır ve tüm testleri bunun üzerinden gerçekleştirir.
Hemen product classını yazarak devam edelim.
Ürün bilgileri ve gerekli işlemler.
@Entity
@Table(name = "products")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false)
private String name;
@Column(nullable = false, length = 1000)
private String description;
@Column(nullable = false)
private BigDecimal price;
@Column(nullable = false)
private String imageUrl;
@Column(nullable = false)
private String category;
}
Burda en başta da belirttiğimiz gibi stok bilgisi bulunmayacak. Çünkü bu stok servisinin işi.
Price’ın BigDecimal olmasının sebebi ise, virgülden sonra hataların gerçekleşmesini istemememiz.
// double ile
0.1 + 0.2 = 0.30000000000000004 // Yanlış
// BigDecimal ile
0.1 + 0.2 = 0.3 // Doğru
Bilgidğimiz üzere, veritabanı ile java üzerinden konuşabilmek için bu classımızı açıyoruz.
public interface ProductRepository extends JpaRepository<Product, UUID> {
// Kategori bazlı filtreleme
Page<Product> findByCategory(String category, Pageable pageable);
// İsme göre arama
Page<Product> findByNameContainingIgnoreCase(String name, Pageable pageable);
}
Ürün listesinde 1000 ürün varsa hepsini bir anda döndürmek hem yavaş hem de gereksiz. Page ile şunu yapıyoruz:
“Bana 1. sayfayı ver, sayfada 10 ürün olsun” → sadece 10 ürün gelir
page.getContent() // o sayfadaki ürünler
page.getTotalElements() // toplam kaç ürün var (1000)
page.getTotalPages() // toplam kaç sayfa var (100)
page.getNumber() // şu an kaçıncı sayfadasın (0)
Frontend bunu alıp “Sayfa 1/100” şeklinde gösterir.
Optional ile farkı:
Optional → tek bir nesne var mı yok mu? (getProductById) Page → birden fazla nesneyi sayfalı getir (getAllProducts)
// Tek ürün → Optional
Optional<Product> findById(UUID id);
// Çok ürün → Page
Page<Product> findAll(Pageable pageable);
Artık DTO’larımıza geçebiliriz.
Kullanıcı (Admin) yeni ürün oluşturabilir ve mevcut bir ürünü güncelleyebilir ve bir ürün silebilir.
public record CreateProductRequest(
@NotBlank(message = "Name cannot be blank")
String name,
@NotBlank(message = "Description cannot be blank")
String description,
@NotNull(message = "Price cannot be null")
@DecimalMin(value = "0.0", inclusive = false, message = "Price must be greater than 0")
BigDecimal price,
@NotBlank(message = "Image URL cannot be blank")
String imageUrl,
@NotBlank(message = "Category cannot be blank")
String category
) {}
public record UpdateProductRequest(
String name,
String description,
BigDecimal price,
String imageUrl,
String category
) {}
Burda validation yok, çünkü kullanıcı sadece bazı alanları güncellemek isteyebilir, null gelen alanlar güncellenmeyecek.
@NotNull vs @NotBlank
@NotNull → null olamaz, ama boş string olabilir → “” geçer @NotBlank → null da olamaz, boş string de olamaz, sadece boşluk da olamaz → “ “ geçmez
String alanlar için her zaman @NotBlank kullanmalıyız. @NotNull yetmez, “” geçirebilirler.
BigDecimal için @NotBlank kullanamayız çünkü String değil, o yüzden @NotNull kullandık.
public record ProductResponse(
UUID id,
String name,
String description,
BigDecimal price,
String imageUrl,
String category
) {}
Bunu yapmamızın amacı, direkt entity nesnesini döndürmemek. Böylece:
1. Güvenlik: Entity’de hassas alan olabilir. Direkt döndürürsek istemeden dışarı çıkar, ayrıca record immutable’dır yani sadece oluşturulurken set edilebilirler. Sonrasında değiştirilemezler.
2. Esneklik: Response her zaman entity ile birebir aynı olmak zorunda değil.
// Entity'de stok yok (Stock Service'te)
// Ama response'da stok bilgisi göstermek isteyebiliriz
public record ProductResponse(
UUID id,
String name,
BigDecimal price,
int stock // ← entity'de yok, Stock Service'den geliyor
) {}
record yapısının asıl varoluş amacı Immutable (Değiştirilemez) veri taşıyıcıları olmaktır. Bir record nesnesi yaratıldıktan sonra içindeki veriler bir daha asla değiştirilemez. Bu yüzden Java, setter metotlarını üretmez ve bizim de manuel olarak yazmamıza izin vermez.
setName(), setPrice() gibi metotlar yoktur. Çünkü alanların hepsi arka planda private final olarak tanımlanır. Değerleri sadece ilk yaratılış anında verebilirsin.get... ön ekini kullanmazlar. Doğrudan alanın (field) adıyla çağrılırlar.
product.getName()product.name()toString(), equals() ve hashCode() metotları arka planda otomatik olarak kusursuz bir şekilde yazılır.// 1. Constructor ile oluşturma (Zorunludur)
ProductResponse response = new ProductResponse(uuid, "Laptop", 1500.0);
// 2. Değerleri okuma (Getter yerine doğrudan ismini kullanırsın)
String productName = response.name();
// 3. Değerleri değiştirme denemesi (HATA VERİR!)
response.setName("Telefon"); // Böyle bir metot yoktur!
response.name = "Telefon"; // Alanlar final olduğu için erişilemez/değiştirilemez!
@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductRepository productRepository;
@Override
public Page<ProductResponse> getAllProducts(Pageable pageable) {
return productRepository.findAll(pageable)
.map(this::toResponse);
}
@Override
public ProductResponse getProductById(UUID id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found"));
return toResponse(product);
}
@Override
public Page<ProductResponse> getProductsByCategory(String category, Pageable pageable) {
return productRepository.findByCategory(category, pageable)
.map(this::toResponse);
}
@Override
public Page<ProductResponse> searchProducts(String name, Pageable pageable) {
return productRepository.findByNameContainingIgnoreCase(name, pageable)
.map(this::toResponse);
}
@Override
public ProductResponse createProduct(CreateProductRequest request) {
Product product = Product.builder()
.name(request.name())
.description(request.description())
.price(request.price())
.imageUrl(request.imageUrl())
.category(request.category())
.build();
return toResponse(productRepository.save(product));
}
@Override
public ProductResponse updateProduct(UUID id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Product not found"));
// Sadece null olmayan alanları güncelle
if (request.name() != null) product.setName(request.name());
if (request.description() != null) product.setDescription(request.description());
if (request.price() != null) product.setPrice(request.price());
if (request.imageUrl() != null) product.setImageUrl(request.imageUrl());
if (request.category() != null) product.setCategory(request.category());
return toResponse(productRepository.save(product));
}
@Override
public void deleteProduct(UUID id) {
// Ürün var mı kontrol et
if (!productRepository.existsById(id)) {
throw new RuntimeException("Product not found");
}
productRepository.deleteById(id);
}
// Entity → Response dönüşümü
private ProductResponse toResponse(Product product) {
return new ProductResponse(
product.getId(),
product.getName(),
product.getDescription(),
product.getPrice(),
product.getImageUrl(),
product.getCategory()
);
}
}
Az önce de konuştuğumuz için güvenlik ve esneklik sebebi ile, direkt entity’i döndürmek yerine, ProductResponse döndürüyoruz. Bu zaten entitymizin tüm içerikilerini barındırıyor.
Dikkat edilecek noktalar:
map(this::toResponse) — Page içindeki her Product‘ı ProductResponse‘a çevirir. stream().map() gibi düşünüyoruz.
updateProduct‘ta null kontrolü — sadece gönderilen alanlar güncellenir, gönderilmeyenler olduğu gibi kalır.
deleteProduct‘ta önce existsById kontrolü — ürün yoksa anlamlı bir hata fırlatıyoruz.
.map(this::toResponse); Ne yapar?
.map() Ne İşe Yarar? (Dönüştürücü Fabrika)map() fonksiyonu bir koleksiyonun (bir Liste, Stream veya Spring’deki Page nesnesi) içindeki elemanları tek tek dönmek ve onları başka bir formata dönüştürmek için kullanılır.
Bir fabrika bandı gibi düşün:
Product entity’si) giriyor.map() makinesi bu eti işliyor.ProductResponse DTO’su) çıkıyor.this::toResponse Ne Anlama Geliyor? (Method Reference)İşte işin büyüsü burada. Bu yazım şekline Method Reference (Metot Referansı) denir ve aslında uzun uzun yazacağın bir Lambda ifadesinin kısaltılmış halidir.
Bu kodun tam olarak neyin kısaltması olduğunu görünce taşlar anında yerine oturacak:
return productRepository.findAll(pageable)
.map(product -> this.toResponse(product));
productRepository.findAll(pageable) veritabanına gider ve örneğin 20 tane Product entity’si ile dolu bir Page (Sayfa) nesnesi getirir..map(...) devreye girer. Bu 20 ürünü tek tek eline alır.toResponse(Product product) metodunun içine fırlatır.ProductResponse record’u döner. map bunu alır, yeni oluşturduğu boş kutuya (yeni Page nesnesine) koyar.Product dolu değil, içi ProductResponse dolu tertemiz bir Page nesnesi vardır ve bunu return ile dışarı atar.@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/swagger-ui/**",
"/v3/api-docs/**"
).permitAll()
.anyRequest().authenticated());
return http.build();
}
}
Önceki günlerde burdakilerin açıklamasını yaptık, buraya sadece ekstradan @EnableMethodSecurity eklendi. Buranın Userdakinden farkı, userdaki gibi jwt filter kullanmıyoruz.
@EnableMethodSecurity:Güvenliği sadece URL (endpoint) bazında değil, doğrudan Java metotları bazında yönetmemizi sağlar.
Bu anotasyonu eklediğimiz an Spring’in @PreAuthorize, @PostAuthorize, @Secured gibi metot bazlı güvenlik anotasyonları aktif hale getiririz.
Artık metotlarının tepesine gidip, bu metodu kimlerin çalıştırabileceğini doğrudan söyleyebiliriz.
@PostMapping
@PreAuthorize("hasRole('ADMIN')") // Sadece Admin girebilir!
public ProductResponse createProduct(...) {
return productService.createProduct(request);
}
Gibi.
EnableMethodSecurity sayesinde sadece Controller’ları değil, içerideki Service katmanındaki metotları da koruyabiliriz. Örneğin ProductServiceImpl içindeki deleteProduct metodunun başına @PreAuthorize("hasRole('ADMIN')") koyarsak, sistemin neresinden çağrılırsa çağrılsın (başka bir servisten, bir zamanlanmış görevden vs.) o anki kullanıcının Admin yetkisi yoksa metot çalışmaz.
@Bean Olarak Tanımlıyoruz?Spring Boot’a şunu diyoruz: “Al bu benim güvenlik kurallarım. Bunu kendi merkezine (IoC Container) kaydet. Dışarıdan uygulamama bir HTTP isteği geldiğinde, onu benim Controller’larıma ulaştırmadan önce zorunlu olarak bu kurallardan geçir.”
Spring Security arka planda tek bir devasa güvenlik kontrolü yapmaz. İşi küçük, spesifik görevleri olan parçalara böler ve bir “Filtreler Zinciri” oluşturur. Dışarıdan bir istek geldiğinde bu istek uzun bir koridora girer ve sırayla kapılardan (filtrelerden) geçer:
.csrf.disable() diyerek bu kapının kilidini açık bıraktın).JwtAuthFilter burada devreye girer. “Cebinde geçerli bir bilet (Token) var mı?”.anyRequest().authenticated() veya @PreAuthorize kısmı).Eğer istek bu filtrelerin herhangi birinden geçemezse (örneğin token geçersizse veya yetki yoksa), zincir anında kopar ve istek ProductController‘ına asla ulaşamaz. Geriye doğrudan 401 Unauthorized veya 403 Forbidden hatası döner. İşin en güzel yanı da budur; iş mantığı (Service) katmanın bu kötü niyetli trafikle hiç muhatap olmaz.
http.build() Ne Yapar?Bizim http üzerinden yazdığın .csrf(), .sessionManagement(), .authorizeHttpRequests() gibi metotların hepsi birer Builder (İnşa edici) ayarıdır.
En sondaki http.build() komutu ise inşaatın bitişini temsil eder. Tüm bu soyut ayarları alır, derler ve trafiği bizzat yönetecek o nihai, somut nesneyi oluşturur.
Özetle; bu metot bizim tüm web trafiğimizi göpüsleyen, filtreleyen ve sadece kurallara uyan istekleri içeri alan muazzam bir kalkan sağlıyor.
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
// Tüm ürünleri sayfalı getir
@GetMapping
public ResponseEntity<Page<ProductResponse>> getAllProducts(Pageable pageable) {
return ResponseEntity.ok(productService.getAllProducts(pageable));
}
// Tek ürün getir
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProductById(@PathVariable UUID id) {
return ResponseEntity.ok(productService.getProductById(id));
}
// Kategori bazlı getir
@GetMapping("/category/{category}")
public ResponseEntity<Page<ProductResponse>> getProductsByCategory(
@PathVariable String category,
Pageable pageable) {
return ResponseEntity.ok(productService.getProductsByCategory(category, pageable));
}
// İsme göre ara
@GetMapping("/search")
public ResponseEntity<Page<ProductResponse>> searchProducts(
@RequestParam String name,
Pageable pageable) {
return ResponseEntity.ok(productService.searchProducts(name, pageable));
}
// Ürün ekle (Admin)
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ProductResponse> createProduct(
@Valid @RequestBody CreateProductRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(productService.createProduct(request));
}
// Ürün güncelle (Admin)
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable UUID id,
@RequestBody UpdateProductRequest request) {
return ResponseEntity.ok(productService.updateProduct(id, request));
}
// Ürün sil (Admin)
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteProduct(@PathVariable UUID id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
Burda her bir işlemde best practice için uygun kodu döndürüyoruz.
| Kod | Anlamı | Ne zaman |
|---|---|---|
| 200 OK | Başarılı | GET, güncelleme |
| 201 Created | Yeni kaynak oluşturuldu | POST (ürün ekle, kayıt ol) |
| 204 No Content | Başarılı ama dönecek veri yok | DELETE |
| 400 Bad Request | İstek hatalı | Validation hatası |
| 401 Unauthorized | Token yok | Kimlik doğrulama gerekli |
| 403 Forbidden | Token var ama yetki yok | Admin endpoint’ine customer girmeye çalışıyor |
| 404 Not Found | Kaynak bulunamadı | Olmayan ürün ID’si |
| 500 Internal Server Error | Sunucu hatası | Beklenmedik exception |
ResponseEntity.ok(...) // 200
ResponseEntity.status(HttpStatus.CREATED).body(...) // 201
ResponseEntity.noContent().build() // 204
ResponseEntity.badRequest().build() // 400
ResponseEntity.notFound().build() // 404
// 401, 403, 500 → Spring otomatik döner
Yeni annotationlar:
@PathVariable — URL’deki değişkeni alır:
GET /api/products/550e8400-...
@PathVariable UUID id → 550e8400-...
@RequestParam — URL’deki query parametresini alır:
GET /api/products/search?name=laptop
@RequestParam String name → "laptop"
@PreAuthorize("hasRole('ADMIN')") Az önce de bahsettiğimiz gibi, sadece ADMIN rolündeki kullanıcılar çağırabilir.
Bunun aktif olması için kesinlikle:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // ← bunu eklemeliyiz.
@RequiredArgsConstructor
public class SecurityConfig { ... }
Şimdilik her şey tamam! Şimdi server-configimize product-servie’imizin config dosyasını yerleştirmeliyiz.
config-server/src/main/resources/configs/product-service.properties dosyasını oluşturup:
spring.datasource.url=jdbc:postgresql://localhost:5432/product_db
spring.datasource.username=postgres
spring.datasource.password=123456
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
Kendi propertysine ise:
spring.application.name=product-service
server.port=8082
spring.config.import=configserver:http://localhost:8888
eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
Yine Eureka’nın,configin adresini ve kendi portnu giriyoruz.