İçeriğe geç

Hexagonal Mimari — Bağımlılık Oku Hangi Yöne Bakıyor

Orta 10 dk Sık karşılaşılır

Önce şunu oku: SOLID — Beş Harf, Tek Soru , Zengin Domain Modeli — JPA Entity'de Kapsülleme

30 saniyede özet

Controller, Service, Repository dizilimi iş kuralını veritabanına bağlar. İş kuralı kendi ihtiyacını bir arayüzle tanımlarsa ok tersine döner: veritabanı değişse de kural değişmez, tıpkı prize göre değişen şarj adaptörü gibi.

Yurt dışına çıkınca bilgisayarını değiştirmezsin, yalnızca fişin ucundaki adaptörü değiştirirsin. Yazılımda ise çoğu zaman bilgisayarı prize lehimleriz.

Bilgisayar prizi değil, kendi girişini tanımalı.
Adım adım oku
  1. İş kuralı JpaRepository'yi doğrudan import ediyor: bilgisayar prize lehimlenmiş.
  2. Veritabanı değişince iş kuralının kodu da değişmek zorunda kalır.
  3. İş kuralı kendi ihtiyacını bir arayüzle, yani port ile tanımlar. JPA adaptörü bu portu uygular.
  4. Veritabanı MongoDB olunca yalnızca adaptör değişir; iş kuralına dokunulmaz.
  1. Bayt: Veritabanını değiştirmek istedik. Sadece repository'ler değişecek sanıyordum.

  2. Sen: Değişmedi mi?

  3. Bayt: İş kurallarının yarısı da değişti! Hepsi JPA sınıflarını import ediyormuş.

  4. Bayt: Bilgisayar prizi tanımamalı, kendi girişini tanımalı. Oku çevirelim.

Spring Boot projelerinin çoğu aynı üç klasörle başlar: controller, service, repository. Düzenli görünür. Ama service içindeki iş kuralı, bir JpaRepository’yi import ettiği anda verinin nasıl saklandığını bilmeye başlar.

Ok nereye bakıyor?

Klasik yapıda istek yukarıdan aşağı akar ve bağımlılıklar da aynı yöne bakar:

PlaceOrder.java (katmanlı)
package com.shop.application;
import com.shop.infrastructure.OrderJpaRepository; // JpaRepository<Order, Long>
@Service
public class PlaceOrder {
private final OrderJpaRepository orders;
…
}

Bu import bir oktur: application → infrastructure. Ok değişikliğin yayılacağı yönü gösterir. Veritabanını, sorgu stratejisini ya da Spring Data sürümünü değiştirdiğinde iş kuralını da açman gerekebilir.

Test tarafında da bedeli var. PlaceOrder’ı sınamak için ya bir veritabanı ayağa kaldırırsın ya da JpaRepository’nin onlarca metodunu taklit edersin.

Kafam karıştı, daha basit anlat

İş kuralın veritabanını tanıyorsa, veritabanı değiştiğinde iş kuralın da değişmek zorunda kalır. Bu okun yönü yanlıştır.

Hızlı kontrolBaşlangıç

PlaceOrder use case'i com.shop.infrastructure.OrderJpaRepository'yi import ediyor. Bu bağımlılık oku ne anlatır?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Priz ve adaptör: oku çevirmek

hexagonal mimariİş kuralını merkeze koyup dış dünyayla (veritabanı, HTTP, mesaj kuyruğu) yalnızca arayüzler üzerinden konuşan mimari. Bağımlılıklar her zaman merkeze doğru bakar.Sözlükte gör → iş kuralını merkeze koyar. Merkez, dış dünyadan ne beklediğini kendi arayüzleriyle tanımlar. Bu arayüzlere ve onları uygulayan sınıflara port ve adapterPort, iş kuralının dış dünyadan beklediğini tanımlayan arayüzdür ve iş kuralının paketinde durur. Adapter, onu belirli bir teknolojiyle (JPA, REST, Kafka) uygulayan sınıftır.Sözlükte gör → denir:

Port domain'de, adapter infrastructure'da
// com.shop.domain — iş kuralının dili
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
// com.shop.infrastructure — teknolojinin dili
interface OrderJpaRepository extends JpaRepository<OrderEntity, Long> {}
@Component
class JpaOrderAdapter implements OrderRepository {
private final OrderJpaRepository jpa;
public Order save(Order order) { return jpa.save(OrderEntity.from(order)).toDomain(); }
…
}

Artık PlaceOrder yalnızca OrderRepository’yi tanıyor. Ok infrastructure → domain yönünde: altyapı iş kuralına uyuyor, tersi değil. Bu, bağımlılık tersine çevirmeİş katmanının neye ihtiyaç duyduğunu kendi tanımlaması, altyapının ona uyması. Arayüzün varlığı değil, hangi katmana ait olduğu belirleyicidir.Sözlükte gör → ilkesinin paket düzeyindeki hâlidir.

Spring tarafında ek bir şey gerekmez. PlaceOrder kurucusunda OrderRepository ister, Spring port’u uygulayan tek bean’i, yani adapter’ı enjekte eder.

Bir banka havalesinde görelim: testte port’u küçük bir sahte sınıfla değiştiriyoruz.

Derinleş · Hesaplar arası havale: port ve adapter 4 dosya · ~72 satır · ilk okumada atlayabilirsin
Proje dosyaları

src/main/java/com/bank/transfer/domain/ Accounts.java Port domain'de durur ve iş kuralının dilini konuşur: IBAN ile hesap bul, hesabı kaydet.

src/main/java/com/bank/transfer/domain/Accounts.java
// Outgoing port: what the transfer rule needs, in its own words.
public interface Accounts {
Optional<Account> byIban(Iban iban);
void save(Account account);
}

src/main/java/com/bank/transfer/application/ SendInternalTransfer.java Use case yalnızca port'u tanır; bakiye kuralı Account'un kendi metotlarında.

src/main/java/com/bank/transfer/application/SendInternalTransfer.java
@Service
public class SendInternalTransfer {
private final Accounts accounts;
public SendInternalTransfer(Accounts accounts) {
this.accounts = accounts;
}
@Transactional
public void send(Iban from, Iban to, Money amount) {
Account source = accounts.byIban(from).orElseThrow(() -> new UnknownAccountException(from));
Account target = accounts.byIban(to).orElseThrow(() -> new UnknownAccountException(to));
source.debit(amount); // refuses insufficient funds and a currency mismatch
target.credit(amount);
accounts.save(source);
accounts.save(target);
}
}

src/main/java/com/bank/transfer/infrastructure/ JpaAccountsAdapter.java Adapter altyapıda durur, port'u Spring Data ile uygular.

src/main/java/com/bank/transfer/infrastructure/JpaAccountsAdapter.java
@Component
class JpaAccountsAdapter implements Accounts {
private final AccountJpaRepository jpa; // extends JpaRepository of AccountEntity
JpaAccountsAdapter(AccountJpaRepository jpa) {
this.jpa = jpa;
}
@Override
public Optional<Account> byIban(Iban iban) {
return jpa.findByIban(iban.value()).map(AccountEntity::toDomain);
}
@Override
public void save(Account account) {
// The entity carries the version column, so a concurrent update still fails loudly.
jpa.save(AccountEntity.from(account));
}
}

src/test/java/com/bank/transfer/application/ SendInternalTransferTest.java Test port'u bellekteki bir sahteyle değiştirir; veritabanı gerekmez.

src/test/java/com/bank/transfer/application/SendInternalTransferTest.java
class SendInternalTransferTest {
private static final Currency TRY = Currency.getInstance("TRY");
private static final Iban ALICE = new Iban("TR330006100519786457841326");
private static final Iban BOB = new Iban("TR320010009999901234567890");
// A hand-written fake: the port is small enough to implement in a few lines.
private final Map<Iban, Account> store = new HashMap<>();
private final Accounts accounts = new Accounts() {
public Optional<Account> byIban(Iban iban) { return Optional.ofNullable(store.get(iban)); }
public void save(Account account) { store.put(account.iban(), account); }
};
@Test
void movesMoneyBetweenTwoAccounts() {
accounts.save(Account.open(ALICE, new Money(new BigDecimal("1000.00"), TRY)));
accounts.save(Account.open(BOB, new Money(BigDecimal.ZERO, TRY)));
new SendInternalTransfer(accounts).send(ALICE, BOB, new Money(new BigDecimal("250.00"), TRY));
assertThat(store.get(ALICE).balance()).isEqualTo(new Money(new BigDecimal("750.00"), TRY));
assertThat(store.get(BOB).balance()).isEqualTo(new Money(new BigDecimal("250.00"), TRY));
}
}
Kafam karıştı, daha basit anlat

Merkez “bana şu şekilde bir priz lazım” der. Veritabanı ya da dış servis, o prize uyan bir fiş takar. Böylece fişi değiştirmek merkezi hiç etkilemez.

Hızlı kontrolOrta

Hexagonal mimaride OrderRepository arayüzü (port) hangi pakette durmalı?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

PlaceOrder kurucusunda OrderRepository (port) istiyor. Spring hangi nesneyi verir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Satır satır: kuralı ArchUnit ile korumak

Paket kuralını yazıya dökmek yetmez. İlk acele eden kişi domain’e bir JPA import’u ekler ve kimse fark etmez. Kural bir testte durmalı.

domain paketindeki Order sınıfında yalnızca @Entity anotasyonu var, hiçbir JPA sınıfı çağrılmıyor. Aşağıdaki kural bunu ihlal sayar mı? Cevabı göster

Sayar. ArchUnit bytecode’u inceler ve bir sınıfın taşıdığı anotasyonu da o anotasyonun paketine bir bağımlılık olarak görür. @Entity, jakarta.persistence’a bir oktur.

Mimari bir birim testi

ArchitectureTest.java
şu an çalışan satır@AnalyzeClasses(packages = "com.shop")
2class ArchitectureTest {
3
4 @ArchTest
5 static final ArchRule domainIsPure =
6 noClasses().that().resideInAPackage("..domain..")
7 .should().dependOnClassesThat()
8 .resideInAnyPackage("jakarta.persistence..", "org.springframework..");
9
10 @ArchTest
11 static final ArchRule useCasesDoNotSeeInfrastructure =
12 noClasses().that().resideInAPackage("..application..")
13 .should().dependOnClassesThat().resideInAPackage("..infrastructure..");
14}

Debug

Adım 1/5

ArchUnit com.shop altındaki bütün sınıfların bytecode'u okundu. Uygulama çalıştırılmadı; bu saf bir birim testi.

Java 21UTF-8LF1:1

Sol/sağ ok tuşlarıyla da gezebilirsin.

İlk kural kırmızı. Ya domain’deki @Entity’yi ayrı bir OrderEntity’ye taşırsın ya da kuralı bilinçli olarak gevşetirsin.

İkisi de meşru. Önemli olan, kararın testte görünür olması.

Kafam karıştı, daha basit anlat

Kuralı bir wiki sayfasına yazmak yetmez, biri unutur. ArchUnit, “merkez veritabanını tanımasın” kuralını bir teste çevirir ve ihlal edildiği gün kırmızı yanar.

Hızlı kontrolOrta

Paket kurallarını bir wiki sayfasına yazmak yerine ArchUnit testine koymanın kazancı nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

noClasses().that().resideInAPackage("..domain..").should().dependOnClassesThat().resideInAnyPackage("jakarta.persistence..") kuralı, domain'deki Order sınıfının @Entity anotasyonunu yakalar mı?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Kendin gör

Üç kararı değiştir, adımlayarak kodu, bağımlılık grafiğini, ArchUnit sonucunu ve veritabanı değişikliğinin etkisini gör.

Paket bağımlılıkları — ok hangi yöne bakıyor?

Tohum 790363
PlaceOrder.java
1package com.shop.application;
2
3import com.shop.domain.Order;
4import com.shop.infrastructure.OrderJpaRepository; // JpaRepository<Order, Long>
5
6@Service
7public class PlaceOrder {
8 private final OrderJpaRepository orders;
9
10 public OrderId place(PlaceOrderCommand cmd) {
11 Order order = Order.placeBy(cmd.customerId());
12 return orders.save(order).id();
13 }
14}
Java 21UTF-8LF
Hız
Adım 0

Şu an ne oldu?

PlaceOrder use case'i ve import satırları

Bağımlılıklar import satırlarında başlar. Adımla ve bu satırların paketler arasında hangi okları çizdiğini gör.

Görevler0/3

  • Bir ArchUnit kuralının kırmızıya döndüğünü göraçık

    İpucu

    Varsayılan ayarlarla kurallar aşamasına kadar ilerle.

  • Üç kuralı da geçir ve veritabanı değişikliğini tek pakete indiraçık

    İpucu

    Repository arayüzünü domain'e taşı, JPA anotasyonlarını domain'den çıkar, controller'ı use case'ten geçir.

  • Bilinçli ödün: domain'de JPA kalsın ama use case altyapıyı hiç görmesinaçık

    İpucu

    Pek çok ekip @Entity'yi domain'de bırakır. Port'u yine de domain'e koy ve bedelini oku.

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

  1. Varsayılanla adımla. Repository infrastructure’da, entity’lerde JPA var. Grafikte iki kırmızı ok, veritabanını değiştirmek üç pakete dokunuyor.
  2. Repository’yi port’a çevir. application → infrastructure oku kayboluyor, yerine infrastructure → domain geliyor. Use case artık sahte bir repository ile test edilebiliyor.
  3. Domain’deki JPA’yı kapat. Üç kural da yeşil, veritabanı değişikliği tek pakete indi.
  4. Controller’ın repository’yi doğrudan çağırmasını aç. Tek bir kısayol, temiz grafiği yeniden bozuyor. Üç görevi de tamamla; sonuncusu bilinçli bir ödün.
Hızlı kontrolOrta

Simülatördeki "bilinçli ödün": port domain'de, ama JPA anotasyonları da domain'de. Sonuç ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Tuzaklar

CRUD servise hexagonal giydirmek. Korunacak bir iş kuralı yoksa her tablo için port, adapter ve iki model arasında dönüşüm yalnızca dosya sayısını artırır. Spring Data repository’yi doğrudan kullanmak dürüst bir seçimdir.

Create
Oluştur: yeni kayıt ekle.
Read
Oku: kaydı getir.
Update
Güncelle: kaydı değiştir.
Delete
Sil: kaydı kaldır.

İki model, iki kat bakım. Domain’de saf Order, infrastructure’da OrderEntity tutmak domain’i JPA’dan tamamen kurtarır ama her alan için bir dönüşüm yazmayı gerektirir.

Pek çok ekip @Entity’yi domain’de bırakır ve yalnızca port’u uygular. Bu bir ödündür; kuralı da buna göre yaz.

Katmana göre paketlemek. controller/, service/, repository/ klasörleri bir özelliği dört yere dağıtır.

Özelliğe göre paketle (order/, payment/) ve katmanları her özelliğin içine koy. Sınırlar paket sınırı olur; Spring Modulith ya da ArchUnit ile doğrulanabilir.

Her şey için bir port. Her use case için ayrı bir giriş port’u, her tablo için ayrı bir çıkış port’u açmak, kalıbı törene çevirir. Port bir ihtiyaçtır; ihtiyaç yoksa port da yoktur.

Kendini sına

Şimşek turu1/5

İş kuralı bir JpaRepository'yi import ederse veritabanı değişikliği kurala kadar ulaşır.

Soru 1/3İleri

Hangi serviste port/adapter katmanı büyük olasılıkla gereksiz karmaşa olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Aklında kalacak üç şey

  1. 1 Bağımlılık oku, bir değişikliğin nereye yayılacağını gösterir. İş kuralı bir JpaRepository'yi import ediyorsa, veritabanı değişikliği kurala kadar ulaşır.
  2. 2 Port, iş kuralının ihtiyacını tanımlayan arayüzdür ve onu kullanan tarafta durur. Adapter onu altyapı tarafında uygular.
  3. 3 Mimari kural bir wiki sayfasında değil, ArchUnit testinde durmalı. Bu yaklaşım da her yerde gerekmez: basit CRUD servislerinde fazladan yüktür.
Sonraki kapı Kodu baştan düzenledin ama davranışı aynı kaldı. Bunu nereden biliyorsun? Code Smell ve Refactoring — Davranışı Değiştirmeden Değiştirmek · 8 dk

5 kart sonraki derste seni bekliyor

0/5 kart bu dersten toplandı