Java'da OOP - 4 Yazılık Serinin 1. Bölümü
Geçenlerde günlük olarak kullandığım bazı konuları tanımlayamadığımı acı bir şekilde fark ettim.
Sanıyorum ki bir şeyi kullanabilmek ile nasıl ve neden kullanabildiğini anlatabilmek farklı yetkinlikler.
Bu yüzden bu seriyi yazmaya karar verdim. Bu vesileyle kafamdaki bilgileri oturtmak ve yazdığım en ufak satırı neden yazdığımı bilmeyi amaçlıyorum.
4 bölüm olarak düşündüm: Encapsulation, Inheritance, Polymorphism, Abstraction. Hepsini önce klasik Java ile, sonra Spring Boot ile örneklendireceğim.
Encapsulation nedir?
Birine encapsulation nedir diye sorunca, çoğu zaman şu cevabı alıyoruz: "field'ları private yapıyorsun."
Teknik olarak yanlış değil diye düşünüyorum. Yalnız asıl meseleyi tam olarak anlatmıyor gibi.
Encapsulation şu demek: veriyi ve o veriye ait kuralları aynı yerde tutmak. Dışarıdan "istediğimi yaparım" diyenlere izin vermemek. Örnekler üzerinden inceleyelim.
Encapsulation olmadan Java nasıl oluyor?
Diyelim ki bir banka hesabı sınıfı yazdık:
public class BankAccount {
public String accountNumber;
public double balance;
public String ownerName;
}
Her şey public. Dışarıdan istediğimiz müdahaleyi yapabiliyoruz:
BankAccount account = new BankAccount();
account.balance = -99999; // eksi bakiye - kimse durdurmadı
account.accountNumber = null; // null hesap numarası - sorun yok
account.ownerName = ""; // boş isim - tamam mı?
Hiçbir kuralımız yok.
1. Eksi bakiye
Başka bir developer bizim sınıfı kullandı diyelim, yanlışlıkla sayının başına - ekleyip account.balance'ı negatif yapıyor. Bunu kontrol edecek hiçbir mekanizmamız yok.
2. Kural 10 yere dağıldı
Projede 10 farklı yerde account.balance -= amount yazıldı. Sonradan "bakiye eksi olamaz" kuralı eklenmek istendi. İşte bu durumda bu 10 yeri tek tek bulmak ve tek tek düzeltmek gerekecek.
3. NullPointerException
Yeni gelen bir developer hesap numarasının null olamayacağını bilmiyor diyelim, account.accountNumber = null yazıyor. Uygulama ilerleyen bir noktada hata fırlatacak, bizim bu hatayı bulmamız belki de saatlerimizi alabilir. İşte bu durumların önüne geçmek için biz Encapsulation kullanıyoruz.
Şimdi aynı sınıfı Encapsulation ile adım adım düzeltelim
Önce field'ları private yapıyoruz:
public class BankAccount {
private String accountNumber;
private double balance;
private String ownerName;
Bu üç field artık dışarıdan direkt erişilemiyor. account.balance = 999 yazmak artık mümkün değil.
Constructor'da nesne oluşturulurken validasyon yapıyoruz:
public BankAccount(String accountNumber, String ownerName) {
if (accountNumber == null || accountNumber.isEmpty()) {
throw new IllegalArgumentException("Account number cannot be empty");
}
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.balance = 0;
}
Hesap numarası boş veya null olamaz. Başlangıç bakiyesi her zaman 0'dan başlar.
Not: Bu örnekte Rich Domain Model yaklaşımını kullandım — nesne oluşturulduğu andan itibaren geçerli bir durumda olması bekleniyor. Birçok Spring projesinde Anemic Domain Model'e yakın yapılar görülür. Validasyonlar service katmanına taşınır ve
@Valid,@NotNullgibi anotasyonlar kullanılır.
Getter'lar sadece okuma izni veriyor:
public double getBalance() {
return balance;
}
public String getAccountNumber() {
return accountNumber;
}
public String getOwnerName() {
return ownerName;
}
Field'lar private olduğu için dışarıdan direkt okunamıyor. Getter'lar bu okuma iznini kontrollü şekilde açıyor. Dikkat edelim ki setter yazmadık. Çünkü bakiyeyi keyfi olarak set ettirmek istemiyoruz, bunun için deposit() ve withdraw() fonksiyonlarımız var.
Para yatırma işlemi kontrollü:
public void deposit(double amount) {
// diğer iş kuralları
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero");
}
balance += amount;
}
Sıfır veya eksi para yatıramayız. Kuralımız tek bir yerde. İleride bir gün herhangi bir kuralı değiştirmek istersek 10 farklı yeri değiştirmemize gerek yok.
Para çekme işlemi de kontrollü:
public void withdraw(double amount) {
// diğer iş kuralları
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be greater than zero");
}
if (amount > balance) {
throw new IllegalArgumentException("Insufficient balance");
}
balance -= amount;
}
}
Basit iki kural koyduk. Miktar sıfırdan büyük olmalı, bakiyenden fazla çekemezsin. Tabii gerçek bir uygulamada yorum satırının olduğu yere farklı iş kuralları ve akışlar olacaktır.
Şimdi dışarıdan nasıl kullanacağız ona bakalım:
BankAccount account = new BankAccount("TR123456", "Emirhan");
Constructor içinde hesabı oluştururken hesap numarası ve isim vermeyi zorunlu kılmıştık.
account.deposit(1000);
account.withdraw(200);
System.out.println(account.getBalance()); // 800.0
Para yatır, çek, bakiyeyi oku. Hepsi metodlar aracılığıyla, direkt erişimimiz yok.
account.withdraw(9999); // HATA: Insufficient balance
account.balance = -99999; // HATA: balance private, erişilemiyor
Kuralları çiğnememiz mümkün değil.
Peki bu "withdraw() aslında setter değil mi?"
İlk bakışta öyle görünebilir. Ama önemli bir fark var:
| Normal Setter | withdraw() |
|
|---|---|---|
| Ne yapar | Değeri direkt atar | Önce kurala bakar, sonra değiştirir |
| Kontrolü kim tutar | Çağıran kod | Sınıfın kendisi |
| Kural nerede durur | Her yere dağılmış | Tek bir yerde |
Normal setter şunu der: "balance'ı şu değere ayarla." withdraw() şunu der: "eğer kurallar izin veriyorsa şu kadar çek."
Çağıran kod sadece ne istediğini söyler. Sınıf nasıl yapılacağına kendisi karar verir. Aslında encapsulation'ın özü bu.
Encapsulation'ın diğer özellikleri
Bilgiyi gizleme (Information Hiding)
Dışarıdaki kod sınıfın içinin nasıl çalıştığını bilmek zorunda değil. Sadece ne yapabileceğini bilir.
account.withdraw(100); // içeride ne olduğunu bilmeme gerek yok
Değişime karşı esneklik
Yarın balance alanını double'dan BigDecimal'a değiştirsek, dışarıdaki kodu değiştirmemize gerek olmayabilir. Tabi burada servis içindeki farklı metodlarla yapılan işlemlere bu değişikliğin bir etkisi yoksa.
Not: Bu örnekte sadelik için
doublekullandık. Finansal uygulamalardadoubleyerineBigDecimalkullanılmalıdır — kayan noktalı aritmetik hatalar yaratır.
Tek kaynak (Single Source of Truth)
Kurallar, o verinin sahibi olan sınıfta durur. Aynı validasyonu 10 farklı yere kopyalamamıza gerek yok.
Peki Spring Boot'da Encapsulation nasıl oluyor?
Şimdi biraz daha gerçek hayata yaklaşalım. Klasik Java örneğinde her şeyi tek sınıfta hallettik. Ama Spring Boot'ta uygulama katmanlara bölünüyor: entity, repository, service, controller.
Burada da Rich Domain Model ile devam ediyoruz.Kurallar entity'nin içinde kalıyor, encapsulation korunuyor. @Getter Lombok anotasyonu tüm field'lara otomatik getter üretiyo.
Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class BankAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String accountNumber;
private double balance;
private String ownerName;
public BankAccount(String accountNumber, String ownerName) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.balance = 0;
}
public void deposit(double amount) {
// diğer iş kuralları
if (amount <= 0) throw new IllegalArgumentException("Amount must be greater than zero");
this.balance += amount;
}
public void withdraw(double amount) {
// diğer iş kuralları
if (amount <= 0) throw new IllegalArgumentException("Amount must be greater than zero");
if (amount > balance) throw new IllegalArgumentException("Insufficient balance");
this.balance -= amount;
}
}
Klasik Java örneğiyle neredeyse aynı yapı. setBalance yok — dışarıdan balance'a yazma izni yok. @Getter tüm field'lara otomatik getter üretiyor. @Entity bu sınıfın veritabanındaki bir tabloya karşılık geldiğini söylüyor. @NoArgsConstructor(access = AccessLevel.PROTECTED) ise JPA için zorunlu olan boş constructor'ı sadece JPA'nın kullanabileceği şekilde açıyor.
Repository
public interface BankAccountRepository extends JpaRepository<BankAccount, Long> {
Optional<BankAccount> findByAccountNumber(String accountNumber);
}
Repository, veritabanı işlemlerini yapan katman. JpaRepository'yi extend edince save(), findById(), delete() gibi temel CRUD metodları otomatik geliyor.
findByAccountNumber() ise bizim eklediğimiz özel bir metod. Spring, metodun adına bakarak otomatik olarak SQL üretiyor — SELECT * FROM bank_account WHERE account_number = ? gibi.
Service
@Service
public class BankAccountService {
private final BankAccountRepository repository;
public BankAccountService(BankAccountRepository repository) {
this.repository = repository;
}
public void deposit(String accountNumber, double amount) {
BankAccount account = findAccount(accountNumber);
account.deposit(amount);
repository.save(account);
}
public AccountResponse withdraw(String accountNumber, double amount) {
BankAccount account = findAccount(accountNumber);
account.withdraw(amount);
repository.save(account);
return new AccountResponse(account.getAccountNumber(), account.getBalance());
}
public double getBalance(String accountNumber) {
return findAccount(accountNumber).getBalance();
}
private BankAccount findAccount(String accountNumber) {
return repository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new RuntimeException("Account not found"));
}
}
Service burada sadece süreci yönetiyor. Hesabı bul, entity metodunu çağır, kaydet gibi özetlenebilir. Kurallar entity'nin içinde olduğu için service şişmiyor. "Miktar sıfırdan büyük mü, bakiye yeterli mi" kontrollerini service bilmiyor, entity hallediyor.
repository burada private. Controller bu alana direkt erişemiyor. Sadece service'in sunduğu metodları görebiliyor.
Controller
@RestController
@RequestMapping("/api/accounts")
public class BankAccountController {
private final BankAccountService service;
public BankAccountController(BankAccountService service) {
this.service = service;
}
@PostMapping("/{accountNumber}/deposit")
public ResponseEntity<String> deposit(
@PathVariable String accountNumber,
@RequestParam double amount) {
service.deposit(accountNumber, amount);
return ResponseEntity.ok("Deposit successful");
}
@PostMapping("/withdraw")
public ResponseEntity<AccountResponse> withdraw(@RequestBody WithdrawRequest request) {
AccountResponse response = service.withdraw(request.accountNumber(), request.amount());
return ResponseEntity.ok(response);
}
@GetMapping("/{accountNumber}/balance")
public ResponseEntity<Double> getBalance(@PathVariable String accountNumber) {
return ResponseEntity.ok(service.getBalance(accountNumber));
}
}
Controller'ın işi tek: HTTP isteğini al, service'e ilet, cevabı dön. Veritabanını bilmiyor, iş kurallarını bilmiyor. service burada private. Controller repository'ye nasıl erişiyor, entity nasıl çalışıyor, hiçbirini bilmiyor. Aşağıdaki şemada tüm akışı görebiliriz.
DTO (Data Transfer Object)
Şimdiye kadar controller'da direkt String accountNumber ve double amount parametreleri aldık. Gerçek bir uygulamada bunun yerine DTO kullanılır.
DTO, katmanlar arası veri taşıyan nesnemiz oluyor. Entity'yi direkt dışarıya açmak yerine DTO kullanmak hem güvenli hem de encapsulation'ın bir uzantısı denebilir. Entity'nin içini dışarıdan gizliyoruz. Örneğin entity'de password, createdAt, updatedAt gibi dışarıya açmak istemediğimiz field'lar olabilir. Direkt entity döndürürsek bu bilgiler de gider. DTO ile sadece göstermek istediğimiz alanları seçiyoruz.
Bir de şu var: controller'dan gelen veriyi direkt entity'ye maplersek tehlikeli olabilir. Biri request'e id alanı ekleyip var olan bir kaydın üzerine yazabilir. DTO bu kapıyı kapatıyor — sadece izin verdiğimiz alanlar geliyor.
// İstek için DTO
public record WithdrawRequest(String accountNumber, double amount) {}
// Cevap için DTO
public record AccountResponse(String accountNumber, double balance) {}
record keyword'ü Java 14 ile geldi. Constructor, getter, equals, hashCode, toString hepsi otomatik üretiliyor. DTO'lar zaten sadece veri taşıdığı için record tam bu senaryo için biçilmiş kaftan.
Artık controller entity'yi hiç görmüyor. Sadece DTO alıyor, DTO döndürüyor.
Katmanlı mimari ve encapsulation ilişkisi
Şimdi bir adım geri çekilip tüm yapıya baktığımızda şunu görüyoruz:
- Controller sadece Service'i çağırıyor, Repository'yi bilmiyor
- Service sadece Repository'yi çağırıyor, EntityManager'ı bilmiyor
- Repository sadece EntityManager'ı çağırıyor, JDBC'yi bilmiyor
Her katman bir altındakini kullanıyor ama nasıl çalıştığını bilmiyor. Bunu encapsulation olarak adlandırmak tam doğru değil — burada daha çok Separation of Concerns ve Layered Architecture kavramları devreye giriyor. Encapsulation ile ilişkili ama aynı şey değil. Encapsulation veriyi ve kuralı aynı yerde tutmakla ilgiliyken, katmanlı mimari sorumlulukları birbirinden ayırmakla ilgili denebilir.
Yani Controller şunu yapamamalı:
// YANLIŞ - Controller repository'ye direkt erişiyor
bankAccountService.repository.save(account);
// DOĞRU - sadece service metodunu çağırıyor
bankAccountService.withdraw(accountNumber, amount);
Neden? Çünkü Controller repository'ye direkt erişirse iş kuralları atlanabilir. Birisi repository.save() diye direkt çağırırsa entity'deki withdraw() içindeki bakiye kontrolü çalışmaz.
Özet
Encapsulation = "private yaz, getter yaz, setter yaz" değil.
Asıl mesaj şu:
Veriyi ve o veriye ait kuralları aynı yerde tut. Dışarıdaki kod ne yapılacağını söylesin, nasıl yapılacağını bilmesin.
Bunu hem sınıf seviyesinde hem de katmanlı mimaride uyguluyoruz. Farkında olmadan her gün kullanıyoruz.
Okuyan herkese çok teşekkür ederim. Hatalı veya eksik olduğunu düşündüğünüz bir kısım varsa lütfen bana iletin.
Bu yazı 4 bölümlük bir OOP serisinin ilk yazısıydı:
- Bölüm 1: Encapsulation ← şu an buradayız.
- Bölüm 2: Inheritance
- Bölüm 3: Polymorphism
- Bölüm 4: Abstraction
Emirhan Kaya - Backend Software Developer
LinkedIn · GitHub · tremirhankaya@gmail.com

Top comments (0)