Katmanlı Mimari
Giriş
Bir Spring Boot uygulaması büyüdükçe kodun düzeni kritik hale gelir. Controller'da veritabanı sorgusu yapan, service'te HTTP response oluşturan, her yere saçılmış iş mantığı — bu kaos, bakımı imkansız kılar. İlk 500 satırda sorun yokmuş gibi görünür ama 5000 satırda proje bir spagetti tabağına döner.
Bunu bir hastaneye benzetin: resepsiyonist (Controller) hastayı karşılar ve yönlendirir. Doktor (Service) teşhis koyar ve tedavi planı yapar. Laboratuvar (Repository) testleri yapar ve sonuçları depolar. Resepsiyonist ameliyat yapmaz, doktor kan testi almaz, laboratuvar hasta kaydı tutmaz. Her birim kendi işini yapar.
Katmanlı mimari, kodu sorumluluk alanlarına göre katmanlara ayırarak düzen, test edilebilirlik ve sürdürülebilirlik sağlar. Bu derste Controller → Service → Repository katman yapısını, DTO kullanımını, bağımlılık yönünü, mapper'ları ve paket organizasyonunu derinlemesine inceleyeceğiz.
Üç Katman Modeli
Spring Boot uygulamalarında standart katman yapısı:
┌──────────────────────────────────────┐
│ Controller Layer │ ← HTTP isteklerini karşılar
│ (REST API, request/response) │ Validasyon, DTO mapping
├──────────────────────────────────────┤
│ Service Layer │ ← İş mantığı (business logic)
│ (Business rules, orchestration) │ Transaction yönetimi
├──────────────────────────────────────┤
│ Repository Layer │ ← Veri erişimi (data access)
│ (Database operations, JPA) │ CRUD operasyonları
├──────────────────────────────────────┤
│ Database / External │
└──────────────────────────────────────┘Her katmanın tek bir sorumluluğu vardır ve sadece bir alt katmanla iletişim kurar. Bu basit kural, kodun karmaşıklığını dramatik şekilde azaltır.
Controller Katmanı
Controller, HTTP isteklerini karşılar, request body'yi validate eder, service katmanını çağırır ve response oluşturur. İş mantığı içermez.
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
@Validated
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
// Controller'ın işi: request al, service'e ilet, response dön
OrderResponse response = orderService.createOrder(request);
URI location = URI.create("/api/v1/orders/" + response.id());
return ResponseEntity.created(location).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(
@PathVariable Long id) {
return ResponseEntity.ok(orderService.getOrderById(id));
}
@GetMapping
public ResponseEntity<Page<OrderResponse>> listOrders(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(defaultValue = "createdAt,desc") String sort) {
return ResponseEntity.ok(
orderService.listOrders(PageRequest.of(page, size,
Sort.by(parseSortDirection(sort), parseSortField(sort)))));
}
@PutMapping("/{id}/status")
public ResponseEntity<OrderResponse> updateStatus(
@PathVariable Long id,
@Valid @RequestBody UpdateStatusRequest request) {
return ResponseEntity.ok(
orderService.updateStatus(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
orderService.cancelOrder(id);
return ResponseEntity.noContent().build();
}
}Controller'da OLMAMASI Gerekenler
// ❌ KÖTÜ — iş mantığı Controller'da
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
// ❌ Stok kontrolü burada olmamalı
Product product = productRepository.findById(request.productId())
.orElseThrow();
if (product.getStock() < request.quantity()) {
throw new InsufficientStockException();
}
// ❌ Fiyat hesaplama burada olmamalı
BigDecimal total = product.getPrice()
.multiply(BigDecimal.valueOf(request.quantity()));
BigDecimal discount = total.multiply(BigDecimal.valueOf(0.1));
BigDecimal finalPrice = total.subtract(discount);
// ❌ Veritabanı işlemi burada olmamalı
Order order = new Order();
order.setTotal(finalPrice);
orderRepository.save(order);
return ResponseEntity.ok(/* ... */);
}Controller sadece bir "trafik polisi"dir — isteği alır, doğru yere yönlendirir, cevabı geri iletir. Bu kadar.
Service Katmanı
İş mantığının kalbidir. Transaction yönetimi, iş kuralları, orkestrasyon ve doğrulama burada yapılır. Repository katmanını çağırır; Controller'ı bilmez.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final UserRepository userRepository;
private final OrderMapper orderMapper;
private final ApplicationEventPublisher eventPublisher;
private final DiscountService discountService;
@Transactional
public OrderResponse createOrder(CreateOrderRequest request) {
// 1. Kullanıcı kontrolü
User user = userRepository.findById(request.userId())
.orElseThrow(() -> ResourceNotFoundException.user(request.userId()));
// 2. Ürün ve stok kontrolü
List<Product> products = productRepository
.findAllById(request.productIds());
if (products.size() != request.productIds().size()) {
throw new ResourceNotFoundException("Product",
"some products not found");
}
// 3. Stok validasyonu
products.forEach(product -> {
int requested = request.getQuantityFor(product.getId());
if (product.getStock() < requested) {
throw BusinessValidationException.insufficientStock(
product.getSku(), requested, product.getStock());
}
});
// 4. Fiyat hesaplama (iş kuralı)
BigDecimal subtotal = calculateSubtotal(products, request);
BigDecimal discount = discountService
.calculateDiscount(user, subtotal);
BigDecimal total = subtotal.subtract(discount);
// 5. Minimum tutar kontrolü
if (total.compareTo(BigDecimal.TEN) < 0) {
throw new BusinessValidationException(
"Minimum sipariş tutarı 10 TL'dir");
}
// 6. Entity oluştur ve kaydet
Order order = orderMapper.toEntity(request);
order.setUser(user);
order.setSubtotal(subtotal);
order.setDiscount(discount);
order.setTotalAmount(total);
order.setStatus(OrderStatus.PENDING);
Order saved = orderRepository.save(order);
// 7. Stok düş
products.forEach(product -> {
int requested = request.getQuantityFor(product.getId());
product.decreaseStock(requested);
productRepository.save(product);
});
// 8. Domain event yayınla
eventPublisher.publishEvent(
new OrderCreatedEvent(saved.getId(), user.getId(), total));
log.info("Order created | orderId={} | userId={} | total={}",
saved.getId(), user.getId(), total);
return orderMapper.toResponse(saved);
}
public OrderResponse getOrderById(Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> ResourceNotFoundException.order(id));
return orderMapper.toResponse(order);
}
public Page<OrderResponse> listOrders(Pageable pageable) {
return orderRepository.findAll(pageable)
.map(orderMapper::toResponse);
}
@Transactional
public OrderResponse updateStatus(Long id, UpdateStatusRequest request) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> ResourceNotFoundException.order(id));
// İş kuralı: durum geçiş kontrolü
if (!order.getStatus().canTransitionTo(request.newStatus())) {
throw new BusinessValidationException(
String.format("Sipariş durumu %s'den %s'e geçemez",
order.getStatus(), request.newStatus()));
}
order.setStatus(request.newStatus());
return orderMapper.toResponse(orderRepository.save(order));
}
@Transactional
public void cancelOrder(Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> ResourceNotFoundException.order(id));
if (order.getStatus() == OrderStatus.SHIPPED) {
throw new BusinessValidationException(
"Kargoya verilmiş sipariş iptal edilemez");
}
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
// Stok iade et
order.getItems().forEach(item -> {
Product product = item.getProduct();
product.increaseStock(item.getQuantity());
productRepository.save(product);
});
eventPublisher.publishEvent(
new OrderCancelledEvent(order.getId()));
}
private BigDecimal calculateSubtotal(List<Product> products,
CreateOrderRequest request) {
return products.stream()
.map(p -> p.getPrice().multiply(
BigDecimal.valueOf(request.getQuantityFor(p.getId()))))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}@Transactional Kuralları
@Service
@Transactional(readOnly = true) // Sınıf seviyesi: tüm metotlar read-only
public class OrderService {
@Transactional // Bu metot yazma yapıyor → readOnly = false
public OrderResponse createOrder(...) { ... }
// readOnly = true (sınıf seviyesinden miras)
public OrderResponse getOrderById(Long id) { ... }
// readOnly = true — sayfalama sorgusu
public Page<OrderResponse> listOrders(Pageable p) { ... }
@Transactional // Yazma işlemi
public void cancelOrder(Long id) { ... }
}💡 İpucu:
readOnly = truesadece bir ipucu değildir — Hibernate dirty checking'i devre dışı bırakır, bazı veritabanları read replica'ya yönlendirir. Performans farkı yaratır.
Repository Katmanı
Veri erişim katmanı. Spring Data JPA ile minimal kod yazarak CRUD ve custom sorgular tanımlarsınız:
public interface OrderRepository extends JpaRepository<Order, Long> {
// Derived query — metod adından sorgu türetilir
List<Order> findByUserIdAndStatus(Long userId, OrderStatus status);
Optional<Order> findByOrderNumber(String orderNumber);
boolean existsByOrderNumber(String orderNumber);
// JPQL — entity-based sorgu
@Query("SELECT o FROM Order o WHERE o.createdAt >= :since " +
"AND o.status = :status")
Page<Order> findRecentByStatus(
@Param("since") LocalDateTime since,
@Param("status") OrderStatus status,
Pageable pageable);
// Projeksiyon — sadece gerekli alanları çek
@Query("SELECT new com.example.dto.OrderSummary(" +
"o.id, o.orderNumber, o.totalAmount, o.status, o.createdAt) " +
"FROM Order o WHERE o.user.id = :userId")
List<OrderSummary> findSummariesByUserId(@Param("userId") Long userId);
// Toplu güncelleme — performans için
@Modifying
@Query("UPDATE Order o SET o.status = :status " +
"WHERE o.status = :fromStatus " +
"AND o.createdAt < :before")
int bulkUpdateStatus(
@Param("status") OrderStatus status,
@Param("fromStatus") OrderStatus fromStatus,
@Param("before") LocalDateTime before);
// İstatistik sorgusu
@Query("SELECT COUNT(o) FROM Order o " +
"WHERE o.status = :status " +
"AND o.createdAt >= :since")
long countByStatusSince(
@Param("status") OrderStatus status,
@Param("since") LocalDateTime since);
}⚠️ Dikkat: Repository katmanında iş mantığı OLMAZ. Repository sadece "veriyi getir, kaydet, güncelle, sil" işlerini yapar. "Stok yeterli mi?", "Sipariş iptal edilebilir mi?" gibi kararlar Service katmanına aittir.
DTO'lar — Katmanlar Arası Veri Taşıma
DTO (Data Transfer Object), katmanlar arasında veri taşıyan basit nesnelerdir. Entity'leri doğrudan API'den döndürmek ciddi sorunlara yol açar:
Döngüsel referanslar:
Order → User → Orders → ...JSON serialization sonsuz döngüGüvenlik riski:
password,internalNotesgibi alanlar client'a giderTight coupling: API değişiklikleri entity'yi, entity değişiklikleri API'yi etkiler
Performans: Tüm ilişkiler lazy/eager yüklenir, gereksiz sorgular
// ===== Request DTO'ları — Client'tan gelen veri =====
public record CreateOrderRequest(
@NotNull(message = "Kullanıcı ID boş olamaz")
Long userId,
@NotEmpty(message = "En az bir ürün seçilmelidir")
List<@Valid OrderItemRequest> items,
@NotBlank(message = "Teslimat adresi boş olamaz")
@Size(max = 500)
String shippingAddress,
String notes
) {
public record OrderItemRequest(
@NotNull Long productId,
@Min(1) @Max(100) int quantity
) {}
public int getQuantityFor(Long productId) {
return items.stream()
.filter(i -> i.productId().equals(productId))
.mapToInt(OrderItemRequest::quantity)
.sum();
}
public List<Long> productIds() {
return items.stream()
.map(OrderItemRequest::productId)
.distinct()
.toList();
}
}
// ===== Response DTO'ları — Client'a dönen veri =====
public record OrderResponse(
Long id,
String orderNumber,
String customerName, // User entity'den sadece isim
BigDecimal subtotal,
BigDecimal discount,
BigDecimal totalAmount,
String status,
List<OrderItemResponse> items,
String shippingAddress,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
public record OrderItemResponse(
Long productId,
String productName,
String productSku,
BigDecimal unitPrice,
int quantity,
BigDecimal lineTotal
) {}
}
// ===== Update DTO'ları =====
public record UpdateStatusRequest(
@NotNull OrderStatus newStatus
) {}Mapper — Entity ↔ DTO Dönüşümü
Mapper'lar Entity ile DTO arasındaki dönüşümü yapar. Manuel mapper veya MapStruct kullanabilirsiniz:
Manuel Mapper
@Component
public class OrderMapper {
public Order toEntity(CreateOrderRequest request) {
Order order = new Order();
order.setShippingAddress(request.shippingAddress());
order.setNotes(request.notes());
return order;
}
public OrderResponse toResponse(Order order) {
List<OrderResponse.OrderItemResponse> itemResponses =
order.getItems().stream()
.map(this::toItemResponse)
.toList();
return new OrderResponse(
order.getId(),
order.getOrderNumber(),
order.getUser().getFullName(),
order.getSubtotal(),
order.getDiscount(),
order.getTotalAmount(),
order.getStatus().name(),
itemResponses,
order.getShippingAddress(),
order.getCreatedAt(),
order.getUpdatedAt()
);
}
private OrderResponse.OrderItemResponse toItemResponse(OrderItem item) {
return new OrderResponse.OrderItemResponse(
item.getProduct().getId(),
item.getProduct().getName(),
item.getProduct().getSku(),
item.getUnitPrice(),
item.getQuantity(),
item.getUnitPrice().multiply(
BigDecimal.valueOf(item.getQuantity()))
);
}
}MapStruct Mapper (Otomatik)
@Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Order toEntity(CreateOrderRequest request);
@Mapping(source = "user.fullName", target = "customerName")
@Mapping(source = "status", target = "status",
qualifiedByName = "statusToString")
OrderResponse toResponse(Order order);
@Named("statusToString")
default String statusToString(OrderStatus status) {
return status.name();
}
}Bağımlılık Yönü (Dependency Direction)
Katmanlar arasında bağımlılık yukarıdan aşağıya akar. Hiçbir katman kendisinin üstündeki katmana bağımlı olmamalıdır:
Controller → Service → Repository
✓ ✓ ✓
Repository → Service → Controller
✗ ✗ ✗Bu kural sayesinde:
Repository'yi değiştirdiğinizde Controller etkilenmez
Service katmanı bağımsız olarak test edilebilir (mock repository ile)
Katmanlar bağımsız olarak geliştirilebilir
Veritabanı teknolojisi değiştiğinde sadece Repository katmanı değişir
// ❌ YANLIŞ — Repository, Controller'ı biliyor
@Repository
public class OrderRepositoryImpl {
// HttpServletRequest KULLANILMAMALI — bu Controller'ın işi
public List<Order> findByRequest(HttpServletRequest request) { ... }
}
// ❌ YANLIŞ — Service, HTTP detaylarını biliyor
@Service
public class OrderService {
// ResponseEntity döndürmemeli — bu Controller'ın işi
public ResponseEntity<OrderResponse> getOrder(Long id) { ... }
}
// ✅ DOĞRU — her katman kendi sorumluluğunda
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatus(OrderStatus status); // Sadece veri erişimi
}
@Service
public class OrderService {
public OrderResponse getOrder(Long id) { ... } // İş mantığı + DTO
}
@RestController
public class OrderController {
public ResponseEntity<OrderResponse> getOrder(...) { ... } // HTTP
}Paket Yapısı: by-Layer vs by-Feature
By-Layer (Katmana Göre)
com.example.myapp/
├── controller/
│ ├── OrderController.java
│ ├── ProductController.java
│ └── UserController.java
├── service/
│ ├── OrderService.java
│ ├── ProductService.java
│ └── UserService.java
├── repository/
│ ├── OrderRepository.java
│ ├── ProductRepository.java
│ └── UserRepository.java
├── model/
│ ├── Order.java
│ ├── Product.java
│ └── User.java
└── dto/
├── OrderDto.java
├── ProductDto.java
└── UserDto.javaAvantaj: Basit, her katman net ayrılmış. Dezavantaj: Bir feature üzerinde çalışırken birçok paketi dolaşmanız gerekir. Proje büyüdükçe her paket şişer (20+ Controller aynı pakette).
By-Feature (Özelliğe Göre) — Önerilen
com.example.myapp/
├── order/
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ ├── Order.java
│ ├── OrderMapper.java
│ ├── CreateOrderRequest.java
│ ├── OrderResponse.java
│ └── OrderStatus.java
├── product/
│ ├── ProductController.java
│ ├── ProductService.java
│ ├── ProductRepository.java
│ ├── Product.java
│ └── ProductResponse.java
├── user/
│ ├── UserController.java
│ ├── UserService.java
│ ├── UserRepository.java
│ ├── User.java
│ └── UserResponse.java
└── common/
├── exception/
│ ├── AppException.java
│ ├── ResourceNotFoundException.java
│ └── GlobalExceptionHandler.java
├── config/
│ ├── SecurityConfig.java
│ └── JpaConfig.java
└── util/Avantaj: İlgili tüm dosyalar bir arada (high cohesion). Bir feature'ı silmek veya mikroservice'e çıkarmak kolay. Feature bazında code review. Dezavantaj: Başlangıçta over-engineering hissi verebilir.
💡 Tavsiye: Yeni projelere by-feature ile başlayın. Proje büyüdükçe bu yapının avantajlarını net olarak göreceksiniz. Bir feature'ı mikroservise çıkarmak istediğinizde, sadece paketi taşırsınız.
Test Edilebilirlik
Katmanlı mimari, test yazmayı dramatik şekilde kolaylaştırır:
// Service testi — Repository mock'lanır
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private ProductRepository productRepository;
@Mock private UserRepository userRepository;
@Mock private OrderMapper orderMapper;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private DiscountService discountService;
@InjectMocks private OrderService orderService;
@Test
void shouldThrowWhenUserNotFound() {
when(userRepository.findById(999L)).thenReturn(Optional.empty());
CreateOrderRequest request = new CreateOrderRequest(
999L, List.of(), "address", null);
assertThatThrownBy(() -> orderService.createOrder(request))
.isInstanceOf(ResourceNotFoundException.class)
.hasMessageContaining("User");
}
@Test
void shouldCreateOrderSuccessfully() {
// Given
User user = new User(1L, "John");
Product product = new Product(1L, "Laptop", BigDecimal.valueOf(1000), 10);
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
when(productRepository.findAllById(any())).thenReturn(List.of(product));
when(discountService.calculateDiscount(any(), any()))
.thenReturn(BigDecimal.ZERO);
when(orderRepository.save(any())).thenAnswer(inv -> {
Order o = inv.getArgument(0);
o.setId(1L);
return o;
});
when(orderMapper.toResponse(any()))
.thenReturn(new OrderResponse(1L, "ORD-001", "John",
BigDecimal.valueOf(1000), BigDecimal.ZERO,
BigDecimal.valueOf(1000), "PENDING",
List.of(), "address",
LocalDateTime.now(), null));
// When
CreateOrderRequest request = new CreateOrderRequest(
1L,
List.of(new CreateOrderRequest.OrderItemRequest(1L, 1)),
"123 Main St", null);
OrderResponse response = orderService.createOrder(request);
// Then
assertThat(response.id()).isEqualTo(1L);
assertThat(response.status()).isEqualTo("PENDING");
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
}
}// Controller testi — Service mock'lanır
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private OrderService orderService;
@Test
void shouldReturn201WhenOrderCreated() throws Exception {
OrderResponse response = new OrderResponse(
1L, "ORD-001", "John", BigDecimal.valueOf(100),
BigDecimal.ZERO, BigDecimal.valueOf(100), "PENDING",
List.of(), "address", LocalDateTime.now(), null);
when(orderService.createOrder(any())).thenReturn(response);
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"userId": 1,
"items": [{"productId": 1, "quantity": 1}],
"shippingAddress": "123 Main St"
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.status").value("PENDING"));
}
}Yaygın Hatalar
1. Anemic Service (Anemik Servis)
// ❌ KÖTÜ — service sadece repository'ye proxy
@Service
public class UserService {
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
public User saveUser(User user) {
return userRepository.save(user);
}
// Hiçbir iş mantığı yok — neden var bu sınıf?
}Service katmanı iş mantığı içermelidir. Sadece CRUD proxy yapıyorsa, gereksizdir ama yine de gelecekteki iş kuralları için yerini koruyun.
2. Fat Controller
// ❌ KÖTÜ — Controller'da 100 satır iş mantığı
@PostMapping
public ResponseEntity<?> createOrder(@RequestBody Map<String, Object> body) {
// 100 satır veritabanı sorgusu, hesaplama, validasyon...
}3. Service'te HTTP Bilgisi
// ❌ KÖTÜ
@Service
public class OrderService {
public ResponseEntity<Order> getOrder(Long id) { ... }
// Service, ResponseEntity bilmemeli
}Özet
Controller: HTTP isteklerini karşılar, validasyon yapar, response döner — iş mantığı İÇERMEZ
Service: İş mantığının kalbi — transaction yönetimi, iş kuralları, orkestrasyon
Repository: Sadece veri erişimi — CRUD, custom query, projeksiyon
DTO: Entity'ler doğrudan API'den dönmez — request/response DTO'ları kullanın
Bağımlılık yönü: Controller → Service → Repository — asla ters yönde
By-Feature paket yapısı: İlgili tüm dosyalar bir arada — yüksek cohesion, düşük coupling
AI Asistan
Sorularını yanıtlamaya hazır