Hexagonal Mimari: Spring Boot'ta Ports & Adapters Uygulaması
Katmanlı mimaride iş kuralı JPA'ya ve HTTP'ye bağımlıdır; hexagonal mimari bu bağımlılığı tersine çevirir. Spring Boot'ta port ve adaptör yapısını, paket düzenini, ArchUnit ile korunan bağımlılık kuralını, domain–JPA ayrımının bedelini ve bu mimarinin hangi projede değer ürettiğini kod örnekleriyle anlatıyoruz.
Klasik katmanlı mimaride (controller → service → repository) bağımlılık yönü yukarıdan aşağıya, yani iş kuralından veritabanına doğrudur. Sonuç: SiparisService JPA entity'lerini, @Transactional'ı ve HTTP DTO'larını bilir; iş kuralını test etmek için veritabanı gerekir; ERP istemcisi değiştiğinde servis değişir. Hexagonal mimari (ports & adapters) bu yönü tersine çevirir: iş kuralı merkezdedir ve hiçbir çerçeveyi bilmez; veritabanı, HTTP, mesaj kuyruğu ve ERP birer adaptör olarak dışarıda durur.
Bu yazıda deseni Spring Boot'ta somut bir paket yapısı ve kod örnekleriyle kuruyor, ArchUnit ile korunan bağımlılık kuralını, domain–JPA ayrımının gerçek bedelini ve bu mimarinin hangi projede değer ürettiğini anlatıyoruz.
Temel fikir: portlar ve adaptörler
Domain: İş kuralları, değer nesneleri, aggregate'ler. Saf Java; Spring, JPA, Jackson yok.
Giriş portları (inbound ports): Uygulamanın dışarıya sunduğu kullanım senaryoları:
SiparisOlusturmaKullanimi.Çıkış portları (outbound ports): Uygulamanın dışarıdan ihtiyaç duyduğu şeylerin arayüzleri:
SiparisDeposu,StokKaynagi,OlayYayinlayici.Adaptörler: Portları gerçekleyen teknoloji kodu: REST controller (giriş), JPA repository (çıkış), SAP OData istemcisi (çıkış), Kafka üretici (çıkış).
Tek kural vardır: bağımlılıklar içeri bakar. Adaptör domain'i bilir, domain adaptörü bilmez. Bu kural derleme zamanında zorlanmazsa üç ayda erir; bu yüzden aşağıda ArchUnit testi var.
Paket yapısı
com.acme.erp.siparis
├── domain
│ ├── Siparis.java ← aggregate, iş kuralları
│ ├── SiparisNo.java, Para.java ← değer nesneleri
│ └── KrediLimitiKurali.java
├── application
│ ├── port
│ │ ├── in/SiparisOlusturmaKullanimi.java
│ │ └── out/SiparisDeposu.java, StokKaynagi.java, OlayYayinlayici.java
│ └── SiparisOlusturmaServisi.java ← kullanım senaryosunun uygulanışı
└── adapter
├── in/web/SiparisController.java
├── out/persistence/SiparisJpaAdaptoru.java, SiparisEntity.java
└── out/sap/SapStokAdaptoru.javaKod: domain'den adaptöre
// domain — hiçbir anotasyon yok
public class Siparis {
private final SiparisNo no;
private final MusteriId musteri;
private final List<Kalem> kalemler;
private Durum durum;
public static Siparis olustur(MusteriId musteri, List<Kalem> kalemler, KrediLimitiKurali kural, Para acikBakiye, Para limit) {
if (kalemler.isEmpty()) throw new BosSiparis();
var s = new Siparis(SiparisNo.yeni(), musteri, List.copyOf(kalemler));
s.durum = kural.degerlendir(limit, acikBakiye, s.toplam()) == Karar.ONAY_GEREKLI
? Durum.ONAY_BEKLIYOR : Durum.ACIK;
return s;
}
public Para toplam() { return kalemler.stream().map(Kalem::tutar).reduce(Para.SIFIR, Para::topla); }
}// application/port/in — giriş portu
public interface SiparisOlusturmaKullanimi {
SiparisNo olustur(SiparisOlusturmaKomutu komut);
}
// application/port/out — çıkış portları
public interface SiparisDeposu { void kaydet(Siparis s); Optional<Siparis> bul(SiparisNo no); }
public interface StokKaynagi { boolean yeterli(UrunKodu urun, int miktar); }
public interface OlayYayinlayici { void yayinla(Object olay); }
// application — kullanım senaryosu; Spring'in transaction anotasyonu burada kabul edilebilir
@Service
@Transactional
public class SiparisOlusturmaServisi implements SiparisOlusturmaKullanimi {
private final SiparisDeposu depo;
private final StokKaynagi stok;
private final MusteriBilgisi musteriler;
private final OlayYayinlayici olaylar;
@Override
public SiparisNo olustur(SiparisOlusturmaKomutu k) {
k.kalemler().forEach(kl -> { if (!stok.yeterli(kl.urun(), kl.miktar())) throw new StokYetersiz(kl.urun()); });
var m = musteriler.bul(k.musteri());
var s = Siparis.olustur(k.musteri(), k.kalemler(), new KrediLimitiKurali(), m.acikBakiye(), m.krediLimiti());
depo.kaydet(s);
olaylar.yayinla(new SiparisOlusturuldu(s.no(), s.toplam()));
return s.no();
}
}// adapter/out/persistence — JPA burada yaşar, domain'e sızmaz
@Component
class SiparisJpaAdaptoru implements SiparisDeposu {
private final SiparisJpaRepository jpa; // Spring Data arayüzü, SiparisEntity üzerinde
private final SiparisEsleyici esleyici; // MapStruct: Siparis ↔ SiparisEntity
public void kaydet(Siparis s) { jpa.save(esleyici.toEntity(s)); }
public Optional<Siparis> bul(SiparisNo no) { return jpa.findByNo(no.value()).map(esleyici::toDomain); }
}
// adapter/in/web — HTTP burada yaşar
@RestController
@RequestMapping("/api/siparisler")
class SiparisController {
private final SiparisOlusturmaKullanimi olusturma;
@PostMapping
ResponseEntity<SiparisYaniti> olustur(@Valid @RequestBody SiparisIstegi istek) {
SiparisNo no = olusturma.olustur(istek.toKomut());
return ResponseEntity.created(URI.create("/api/siparisler/" + no.value())).body(new SiparisYaniti(no.value()));
}
}Kuralı koruyan test
@AnalyzeClasses(packages = "com.acme.erp")
class MimariKurallariTest {
@ArchTest
static final ArchRule domainKimseyeBagimliDegil = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("..application..", "..adapter..",
"org.springframework..", "jakarta.persistence..", "com.fasterxml..");
@ArchTest
static final ArchRule applicationAdaptoruBilmez = noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat().resideInAPackage("..adapter..");
@ArchTest
static final ArchRule adaptorlerBirbiriniBilmez = slices()
.matching("..adapter.(*)..").should().notDependOnEachOther();
}Bu üç kural CI'da koşarken "controller doğrudan repository'yi çağırdı" ya da "domain'e JPA anotasyonu girdi" hataları pull request aşamasında yakalanır.
Mapping bedeli: domain ile JPA entity'sini ayırmak şart mı?
Hexagonal mimarinin en çok tartışılan noktası budur. Ayrım, domain modelini persistence kaygılarından (lazy proxy, boş constructor, setter zorunluluğu, tablo yapısı) kurtarır; bedeli her aggregate için bir entity ve bir eşleyici sınıfıdır. Pragmatik kuralımız:
Durum | Öneri |
|---|---|
Zengin iş kuralları, değer nesneleri, durum makineleri olan çekirdek modül | Ayır; MapStruct ile eşleme maliyeti düşüktür |
Basit CRUD modülü (parametre tabloları, tanımlar) | Ayırma; JPA entity'si domain'dir, port yine korunur |
Salt okuma / rapor ekranları | Domain'e uğramadan projeksiyon; ayrı okuma modeli |
Legacy tablo yapısı domain'e uymuyor | Kesinlikle ayır; adaptör çeviri yapar |
Test avantajı
Portlar arayüz olduğu için kullanım senaryosu bellek içi sahte (fake) gerçeklemelerle, Spring ve veritabanı olmadan test edilir:
@Test
void stokYetersizseSiparisOlusmaz() {
var depo = new BellekIciSiparisDeposu();
var stok = (StokKaynagi) (urun, miktar) -> false; // lambda: port tek metotlu
var servis = new SiparisOlusturmaServisi(depo, stok, sabitMusteri(), olaylar -> {});
assertThatThrownBy(() -> servis.olustur(komut("10001", 5)))
.isInstanceOf(StokYetersiz.class);
assertThat(depo.kayitSayisi()).isZero();
}Adaptörler ise kendi teknolojisiyle test edilir: JPA adaptörü Testcontainers üzerinde, SAP adaptörü WireMock sözleşmeleriyle. Katmanlar birbirinin testini kirletmez.
Ne zaman değer, ne zaman fazla?
Değer üretir: Uzun ömürlü çekirdek sistemler, çok sayıda dış entegrasyon (ERP, ödeme, e-fatura), değişmesi beklenen altyapı (veritabanı, mesajlaşma), yoğun iş kuralı.
Fazladır: Kısa ömürlü iç araçlar, birkaç ekranlık CRUD, prototipler. Burada katmanlı mimari ve iyi paketleme yeterlidir.
Modüler monolitte her modül kendi hexagonu olabilir: Spring Modulith modül sınırlarını, hexagonal yapı modül içindeki bağımlılık yönünü korur. İkisi rakip değil tamamlayıcıdır.
Tuzaklar
Anemik domain: Domain sınıfları yalnızca alan taşıyor, kurallar servislerde. Hexagonal yapı bunu çözmez; kuralı aggregate'e taşıyın.
Port başına arayüz enflasyonu: Her repository metodu için ayrı port arayüzü gereksizdir; port, kullanım senaryosunun ihtiyaç duyduğu yetenek kümesidir.
Adaptörde iş kuralı: Controller'da "eğer müşteri VIP ise" kontrolü, domain'in dışına sızmış kuraldır.
Her yerde eşleme: DTO → komut → domain → entity zinciri uzarsa okuma modelini ayırın; sorgular domain'den geçmek zorunda değildir.
Sık sorulan sorular
Hexagonal, clean architecture ve onion architecture aynı şey mi?
Aynı fikrin farklı çizimleridir: domain merkezde, bağımlılıklar içeri. Terminoloji (port/adaptör, use case/interface adapter, katman halkaları) değişir; uygulama pratiği yukarıdakiyle aynıdır.
Spring Data repository'si doğrudan port olamaz mı?
Basit modüllerde olabilir: port arayüzünü Spring Data arayüzünün extend etmesi yaygın bir kısayoldur. Domain–entity ayrımı yapılan modüllerde ise araya eşleyen bir adaptör girmelidir.
Mevcut katmanlı projeyi hexagonal'a nasıl çeviririz?
Kademeli: önce çıkış portlarını çıkarın (servisin çağırdığı repository ve istemciler arayüz olsun), sonra domain'i Spring/JPA'dan arındırın, en son giriş portlarını ekleyin. Her adımda ArchUnit kuralını sıkılaştırın.
Sonuç
Hexagonal mimari, iş kuralını çerçevelerden bağımsız kılan ve teknolojiyi değiştirilebilir adaptörlere iten bir bağımlılık disiplinidir. Bedeli mapping ve birkaç ek arayüz, kazancı test edilebilirlik ve on yıl yaşayacak bir çekirdektir. Uzun ömürlü kurumsal sistemlerde bedel neredeyse her zaman ödenmeye değer.
Mevcut uygulamanızın bağımlılık yapısını çıkarmak ve kademeli bir hexagonal geçiş planı kurmak için yazılım tasarımı ve mimari hizmetimize göz atabilirsiniz.