Katmanlı Mimari Nedir? Java Projesinde Katmanlar Nasıl Ayrılır?
Katmanlı mimari, bir uygulamadaki sorumlulukları HTTP isteğini karşılama, iş kuralını çalıştırma ve veriyi saklama gibi farklı görevlere göre ayrı sınıflarda toplama yaklaşımıdır. Java projelerinde bu genellikle controller, service ve repository olmak üzere üç katmana bölünür. Amaç, kodun her parçasının tek bir işten sorumlu olmasını sağlayıp değişiklik yapmayı ve test yazmayı kolaylaştırmaktır.
Katmanlı mimari neden var?
Küçük bir proje büyürken en çok karşılaşılan sorun, tek bir sınıfın gitgide her şeyi yapmaya başlamasıdır. HTTP isteğini okuyan kod, iş kuralını uygulayan kod ve veritabanına SQL yazan kod aynı metodun içinde yan yana durur. Başlangıçta bu hızlı görünür ama proje büyüdükçe iki somut sorun ortaya çıkar.
Birincisi bakım zorluğu. Bir iş kuralını değiştirmek istediğinizde, o kuralın HTTP detaylarıyla ve SQL sorgularıyla iç içe geçmiş olması yüzünden değişikliğin etkisini kestirmek zorlaşır. İkincisi test edilebilirlik sorunu. İş kuralını test etmek istediğinizde gerçek bir veritabanı bağlantısına ve sahte bir HTTP isteğine ihtiyaç duyarsınız, çünkü bu üçü birbirinden ayrılamaz haldedir. Katmanlı mimari, bu üç sorumluluğu ayrı sınıflara bölerek her birinin kendi başına değişebilmesini ve test edilebilmesini sağlar.
Önce tek sınıfla yazalım
Sorunu somutlaştırmak için, sipariş oluşturan bir endpoint'i tek bir sınıfta yazalım:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final JdbcTemplate jdbcTemplate;
public OrderController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostMapping
public ResponseEntity<String> createOrder(@RequestBody Map<String, Object> body) {
String customerId = (String) body.get("customerId");
BigDecimal amount = new BigDecimal(body.get("amount").toString());
// İş kuralı: minimum tutar kontrolü
if (amount.compareTo(BigDecimal.TEN) < 0) {
return ResponseEntity.badRequest().body("Minimum tutar 10 TL");
}
// İş kuralı: müşteri limiti kontrolü
BigDecimal totalSpent = jdbcTemplate.queryForObject(
"SELECT COALESCE(SUM(amount), 0) FROM orders WHERE customer_id = ?",
BigDecimal.class, customerId);
if (totalSpent.add(amount).compareTo(new BigDecimal("10000")) > 0) {
return ResponseEntity.badRequest().body("Müşteri limiti aşıldı");
}
// Veri erişimi: kayıt oluşturma
jdbcTemplate.update(
"INSERT INTO orders (customer_id, amount, created_at) VALUES (?, ?, ?)",
customerId, amount, Instant.now());
return ResponseEntity.ok("Sipariş oluşturuldu");
}
}
Bu kod çalışır ama üç ayrı sorun aynı metotta üst üste biner:
- HTTP isteğini ayrıştırma (
@RequestBody Map) ile iş kuralı aynı yerde - İş kuralları (minimum tutar, müşteri limiti) SQL sorgularıyla iç içe
JdbcTemplatedoğrudan controller'a enjekte edilmiş, veri erişimi sızıntısı var- Bu metodu test etmek için gerçek bir veritabanı gerekiyor, iş kuralını tek başına test edemezsiniz
- Aynı müşteri limiti kontrolü başka bir endpoint'te gerekirse kod kopyalanır
Sorumlulukları ayırmak: controller, service, repository
Bu sorunları çözmek için sorumluluklar üç katmana dağıtılır ve veri tek yönlü akar: controller service'i çağırır, service repository'yi çağırır. Hiçbir katman kendisinden sonraki katmanı atlamaz veya geriye doğru çağrı yapmaz.
- Controller: HTTP isteğini karşılar, gövdeyi okur, uygun service metodunu çağırır ve sonucu HTTP yanıtına çevirir. İş kuralı veya SQL barındırmaz.
- Service: İş kurallarını uygular (minimum tutar, müşteri limiti gibi). Veriye ihtiyaç duyduğunda repository'yi çağırır, ham SQL yazmaz.
- Repository: Veritabanıyla konuşan tek katmandır. Sorgu ve kayıt işlemleri burada toplanır, iş kuralı bilmez.
Bu ayrım sayesinde her katman kendi işiyle ilgilenir ve diğerlerinin detayını bilmek zorunda kalmaz. Sonraki bölümde bu örneği gerçekten üç katmana bölüp kodun nasıl göründüğüne bakacağız.
Aynı örneği katmanlara ayıralım
Önceki bölümde tek sınıfta yazdığımız görev ekleme mantığını şimdi üç katmana bölelim. Aynı iş, sadece sorumluluklar ayrı sınıflarda.
// Controller - HTTP isteğini karşılar
@RestController
@RequestMapping("/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
public ResponseEntity<Task> createTask(@RequestBody TaskRequest request) {
Task task = taskService.createTask(request);
return ResponseEntity.ok(task);
}
}
// Service - iş kuralları burada
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public Task createTask(TaskRequest request) {
if (request.getTitle() == null || request.getTitle().isBlank()) {
throw new IllegalArgumentException("Başlık boş olamaz");
}
Task task = new Task(request.getTitle(), false);
return taskRepository.save(task);
}
}
// Repository - veri erişimi
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
}
Controller artık sadece HTTP ile ilgileniyor, service kuralları uyguluyor, repository veriyi saklıyor. Her sınıfın tek bir değişme sebebi var. Veritabanı sorgusu değişirse repository'ye dokunursun, iş kuralı değişirse service'e, response formatı değişirse controller'a. Değişikliklerin birbirine sızması engellenmiş oluyor.
DTO kavramına kısa giriş
Yukarıdaki örnekte iki farklı sınıf var: TaskRequest ve Task. Bunlar aynı şey değil, kasıtlı olarak ayrılmışlar.
TaskRequest, dışarıdan gelen veriyi temsil eder. İstemcinin gönderdiği JSON'un Java karşılığıdır, sadece title alanı taşır. Task ise uygulamanın iç modelidir, veritabanındaki tabloya karşılık gelir ve id, completed gibi dışarıdan gelmeyen alanları da içerir.
public class TaskRequest {
private String title;
// getter, setter
}
Bu tür sınıflara DTO (Data Transfer Object) denir. Amaç, dış dünyadan gelen veri ile uygulamanın iç modelini birbirinden ayırmak. Böylece veritabanı şeman değişse bile API sözleşmen bozulmaz, ya da API'ye yeni bir alan eklemek istediğinde entity sınıfını değiştirmek zorunda kalmazsın. İki tarafı birbirine bağımlı tutmamak, uzun vadede esneklik sağlar.
Her proje üç katman gerektirmez
Controller, service, repository ayrımı büyüyen projelerde işe yarıyor ama her uygulamada zorunlu değil. Basit bir CRUD işlemi yapan, iş kuralı içermeyen küçük bir servis için service katmanı çoğu zaman sadece repository'yi çağıran boş bir ara katman haline gelir. Bu durumda katman eklemek karmaşıklığı azaltmaz, tam tersine dosya sayısını artırıp okunabilirliği zorlaştırabilir.
Katmanlama ihtiyacını belirlerken bakılabilecek birkaç ölçüt var: iş kuralı var mı, birden fazla veri kaynağına erişim gerekiyor mu, aynı mantığı farklı yerlerden çağırma ihtiyacı olacak mı. Bu sorulardan hiçbirine evet demiyorsan, controller'ın repository'yi doğrudan çağırması yeterli olabilir. Katmanlı mimari tek doğru yapı değildir, projenin büyüklüğüne ve karmaşıklığına göre şekillendirilecek bir araçtır.
Test edilebilirlik açısından fark
Katmanlı mimarinin en somut faydası test yazarken ortaya çıkar. Tek sınıfta her şeyi yapan kodu test etmek istediğinizde, gerçek bir veritabanı bağlantısına ihtiyacınız olur. Bu da testleri yavaşlatır, kırılgan hale getirir ve CI ortamında ekstra kurulum gerektirir.
Service katmanını repository'den ayırdığınızda, repository'yi bir mock ile değiştirip service'i tek başına test edebilirsiniz:
@Test
void kullaniciBulunamazsaHataFirlatmali() {
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1L)).thenReturn(Optional.empty());
UserService service = new UserService(mockRepo);
assertThrows(UserNotFoundException.class,
() -> service.getUser(1L));
}
Bu test hiçbir veritabanına dokunmaz, milisaniyeler içinde çalışır ve service'in iş mantığını izole şekilde doğrular. Repository katmanı ayrı bir sınıf olduğu için, gerçek veritabanı erişimini gerektiren testleri de ayrı tutabilir, sadece kritik noktalarda entegrasyon testi yazabilirsiniz.
Tek sınıflı yaklaşımda bu ayrım mümkün değildir; her testte gerçek bağımlılıkları da beraberinde sürüklersiniz. Katmanlı mimari, bağımlılıkları enjekte edilebilir hale getirerek testleri hem hızlandırır hem de neyi test ettiğinizi netleştirir.
Sık Sorulan Sorular
Katmanlı mimari küçük projelerde gereksiz mi?
Çok küçük, tek seferlik scriptler için gereksiz olabilir. Ama büyüme potansiyeli olan projelerde erkenden katman ayırmak, sonradan yapılacak zorlu bir refactor'ü önler.
Service katmanı olmadan controller doğrudan repository çağırabilir mi?
Teknik olarak mümkündür ama iş mantığının nereye yazılacağı belirsizleşir. Basit CRUD işlemleri için kabul edilebilir, karmaşık mantık için önerilmez.
Mock kullanmak yerine gerçek veritabanıyla test etmek daha mı güvenilir?
Entegrasyon testleri değerlidir ama yavaştır. Birim testlerinde mock kullanmak, iş mantığını hızlıca ve izole doğrulamanızı sağlar; ikisi birbirini tamamlar.
DTO kullanmadan da katmanlı mimari kurulabilir mi?
Evet, DTO opsiyoneldir. Küçük projelerde entity'leri doğrudan taşımak yeterli olabilir, ama büyüyen projelerde katmanlar arası veri sızıntısını önlemek için DTO önerilir.
Katmanlı mimari, kodu büyük bir bulmacaya dönüştürmeden sorumlulukları düzenli tutmanın pratik bir yoludur. Projenizin büyüklüğüne göre katman sayısını ayarlayarak, hem bakımı kolay hem de test edilebilir bir yapı kurabilirsiniz.