DEV Community

Emirhan Kaya
Emirhan Kaya

Posted on

Java ve Spring Boot'ta Encapsulation

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;
}
Enter fullscreen mode Exit fullscreen mode

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ı?
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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, @NotNull gibi 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;
    }
Enter fullscreen mode Exit fullscreen mode

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;
    }
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 double kullandık. Finansal uygulamalarda double yerine BigDecimal kullanı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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

İstek ve cevap akışı

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) {}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)