Modüler Monolit: Spring Modulith ile Modül Sınırları
Modüler monolit ancak sınırlar derleme zamanında zorlanırsa modüler kalır. Spring Modulith ile modül kurallarını, doğrulama testini, olay tabanlı iletişimi ve publication registry'yi, modül bazlı testleri ve otomatik dokümantasyonu kod örnekleriyle anlatıyoruz.
Monolit mi mikroservis mi yazısında kararı vermiştik: çoğu kurumsal Java projesi için başlangıç noktası, sınırları derleme zamanında zorlanan bir modüler monolit. Bu yazı o kararın uygulamasıdır. Modüler monolitin en büyük riski, modülerliğin bir sunum slaydında kalmasıdır: paketler vardır, ama üç ay sonra herkes herkesin repository'sini çağırır. Spring Modulith bu erozyonu bir test hatasına dönüştürür.
Spring Modulith'in ne olduğunu ve ne olmadığını, modül kurallarını, olay tabanlı iletişimi ve publication registry'yi, modül bazlı testleri, otomatik dokümantasyonu ve mevcut bir monoliti nasıl kademeli modülerleştireceğinizi kod örnekleriyle ele alıyoruz.
Spring Modulith nedir, ne değildir?
Spring Modulith bir framework değişikliği değildir; Spring Boot uygulamanızın üzerine gelen bir kurallar ve araçlar setidir. Dört şey sağlar: paket yapısına dayalı modül tanımı ve doğrulaması, modüller arası olay iletişimi için kalıcı bir publication registry, modül bazında bootstrap eden test desteği ve koddan üretilen C4 dokümantasyonu. Mevcut Spring Data, Spring Security veya JPA kodunuz olduğu gibi kalır.
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-jpa</artifactId> <!-- core + JPA tabanlı event registry -->
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-starter-test</artifactId>
<scope>test</scope>
</dependency>Modül kuralları: paket = modül, alt paket = iç
Varsayılan kural basittir: @SpringBootApplication sınıfının paketinin doğrudan altındaki her paket bir modüldür. Modül paketinin kökündeki tipler dışa açıktır; alt paketlerdeki her şey içeridir ve diğer modüllerden erişilemez.
com.acme.erp
├── siparis ← modül
│ ├── SiparisService.java ← API (kök paket)
│ ├── SiparisOlusturuldu.java ← olay (kök paket)
│ ├── internal/ ← diğer modüllere kapalı
│ └── api/ ← @NamedInterface ile açılabilir
├── stok
├── fatura
└── musteriKök paketin şişmesini önlemek için bir alt paketi @NamedInterface ile adlandırılmış arayüz olarak açabilirsiniz; bağımlılık bildiriminde siparis::api biçiminde referans verilir:
// siparis/api/package-info.java
@org.springframework.modulith.NamedInterface("api")
package com.acme.erp.siparis.api;
// stok/package-info.java — stok yalnızca sipariş'in "api" arayüzüne bağımlı olabilir
@org.springframework.modulith.ApplicationModule(
displayName = "Stok",
allowedDependencies = { "siparis::api" }
)
package com.acme.erp.stok;Kuralı koruyan test tek satırdır ve CI'da koşar:
class ModulariteTest {
ApplicationModules modules = ApplicationModules.of(ErpApplication.class);
@Test
void sinirlarKorunuyor() {
modules.verify();
}
@Test
void ihlalleriListele() { // geçiş döneminde: kırmadan raporla
modules.detectViolations().getMessages().forEach(System.out::println);
}
}verify() üç ihlali yakalar: modüller arası döngüsel bağımlılık, iç paketlere erişim ve allowedDependencies dışına çıkan bağımlılık. Eski, henüz bölünmemiş bir kod bloğunu geçici olarak modül saymak için @ApplicationModule(type = Type.OPEN) kullanılabilir; bu, alt paketleri de dışa açar ve yalnızca geçiş için bir kaçış yoludur.
Modüller arası iletişim: çağrı mı, olay mı?
İki mekanizma vardır ve seçim mimari bir karardır:
Durum | Mekanizma | Örnek |
|---|---|---|
Cevap hemen gerekli; çağıran, sonuç olmadan devam edemez | Doğrudan API çağrısı (senkron) | Sipariş oluştururken müşterinin kredi limitini sorgulamak |
İş akışının devamı başka modülün sorumluluğu | Olay (asenkron) | Sipariş oluştu → stok rezervasyonu, fatura taslağı, bildirim |
Modül başka modülün verisini listeleyecek | Olayla beslenen yerel kopya | Rapor modülünün kendi tablosunda müşteri adı tutması |
İki modül aynı transaction'da güncellenmeli | Muhtemelen tek modül olmalılar | Sınırı yeniden düşünün |
Olay tarafında sipariş modülü yalnızca yayınlar; kimin dinlediğini bilmez:
public record SiparisOlusturuldu(SiparisNo no, List<Kalem> kalemler) {}
@Service
@Transactional
public class SiparisService {
private final SiparisRepository repository;
private final ApplicationEventPublisher events;
public SiparisNo olustur(SiparisTalebi talep) {
SiparisEntity s = repository.save(SiparisEntity.from(talep));
events.publishEvent(new SiparisOlusturuldu(s.getNo(), s.kalemler()));
return s.getNo();
}
}// stok modülü
@Component
class StokRezervasyonu {
@ApplicationModuleListener // = @Async + REQUIRES_NEW + @TransactionalEventListener
void on(SiparisOlusturuldu e) {
rezervasyonlar.rezerveEt(e.kalemler());
}
}Event publication registry: kaybolmayan olaylar
Sıradan Spring olaylarında dinleyici hata verirse olay kaybolur. spring-modulith-starter-jpa ile her yayın, sipariş transaction'ının parçası olarak event_publication tablosuna yazılır; dinleyici başarıyla bittiğinde kayıt tamamlanmış işaretlenir. Uygulama çökse bile tamamlanmamış yayınlar veritabanındadır ve yeniden işlenir:
spring:
modulith:
events:
republish-outstanding-events-on-restart: true # açılışta tamamlanmamışları yeniden yayınla
completion-mode: archive # tamamlananları sil / güncelle / arşivle@Component
class TakiliKalanOlaylar {
private final IncompleteEventPublications publications;
@Scheduled(fixedDelay = 60_000)
void yenidenDene() {
publications.resubmitIncompletePublicationsOlderThan(Duration.ofMinutes(5));
}
}Bu, Kafka veya bir outbox tablosu yazmadan elde edilen "en az bir kez teslim" garantisidir. Sonucu: dinleyiciler idempotent olmak zorundadır — aynı sipariş numarası için ikinci kez gelen rezervasyon isteği yeni rezervasyon üretmemelidir.
Dışa açma: aynı olay, Kafka'ya
Bir modül ileride ayrı servise çıkarıldığında ya da başka bir sistem olayı dinleyecekse, yayınlayan kodu değiştirmeden olayı dışarı aktarırsınız:
@Externalized("siparis.olusturuldu::#{#this.no().value()}") // topic::key
public record SiparisOlusturuldu(SiparisNo no, List<Kalem> kalemler) {}spring-modulith-events-kafka bağımlılığıyla olay, transaction commit olduktan sonra belirtilen topic'e yazılır. Modül içi dinleyiciler aynen çalışmaya devam eder.
Modül bazlı testler
Tüm uygulamayı ayağa kaldıran @SpringBootTest yerine yalnızca bir modülü bootstrap edebilirsiniz; bağımlı modüllerin bean'leri mock'lanır:
@ApplicationModuleTest // varsayılan: STANDALONE — sadece bu modül
class StokModulTest {
@MockitoBean MusteriService musteriService; // başka modülden gelen bağımlılık
@Test
void siparisOlusuncaStokRezerveEdilir(Scenario scenario) {
scenario.publish(new SiparisOlusturuldu(new SiparisNo("S-1"), kalemler()))
.andWaitForStateChange(() -> rezervasyonlar.bul("S-1"))
.andVerify(r -> assertThat(r).isPresent());
}
}Scenario API'si asenkron dinleyicileri beklemeyi ve durum değişimini doğrulamayı standartlaştırır. @ApplicationModuleTest(mode = DIRECT_DEPENDENCIES) ile doğrudan bağımlı modüller de gerçek olarak yüklenir. Testlerin süresi, tüm uygulama yerine tek modül yüklendiği için belirgin biçimde kısalır.
Dokümantasyon ve gözlemlenebilirlik
Modül yapısı koddan türetildiği için dokümantasyon da koddan üretilir:
@Test
void dokumantasyonUret() {
new Documenter(modules)
.writeModulesAsPlantUml() // C4 bileşen diyagramı
.writeIndividualModulesAsPlantUml()
.writeModuleCanvases(); // her modülün API, olay ve bean özeti
}Çıktı target/spring-modulith-docs altına yazılır ve CI'da üretildiğinde mimari dokümanı kodla senkron kalır. spring-modulith-observability her modül girişini Micrometer tracing'de ayrı bir span olarak işaretler; böylece tek dağıtım biriminde bile "istek hangi modülde ne kadar zaman geçirdi" sorusu cevaplanır. spring-modulith-actuator ise /actuator/modulith ucunda modül yapısını dışa verir.
Veri sahipliği: sınır tabloya kadar iner
Kod sınırı yeterli değildir; iki modül aynı tabloya yazıyorsa sınır yoktur. Üç kural uyguluyoruz:
Modül başına şema:
siparis.*,stok.*. Flyway migrasyonları modül klasörlerinde tutulur.Modüller arası JPA ilişkisi yok: Sipariş, müşteriyi
@ManyToOne Musteriile değilMusteriIddeğeriyle tutar.Okuma ihtiyacı olayla beslenen yerel kopyayla karşılanır: Rapor modülü müşteri adını kendi tablosuna kopyalar; canlı JOIN atmaz.
Mevcut monoliti kademeli modülerleştirmek
Haritala:
Documenterile mevcut paket bağımlılıklarını çiz; döngüleri işaretle.Ratchet kur:
detectViolations()sayısını CI'da kaydet; sayı artarsa build kırılsın, azalması hedef olsun.Döngüleri olaya çevir: A→B→A zincirlerinde geri yönü olayla değiştir.
İç detayları
internalpaketine taşı: Repository ve entity'ler kökten çıkar; dışarı yalnızca servis ve olay kalır.Veriyi ayır: Ortak tabloları sahibine ver; diğerleri kopya veya API ile beslensin.
Bu sıra, büyük bir yeniden yazma gerektirmeden birkaç sprintte ölçülebilir ilerleme sağlar ve her adımın sonunda uygulama çalışır durumdadır.
Sık sorulan sorular
Spring Modulith, ArchUnit'in yerini mi alıyor?
Modül sınırları için evet, daha az kod ve daha anlamlı hata mesajlarıyla. Katman kuralları (controller repository'yi çağıramaz gibi) için ArchUnit ile birlikte kullanılabilir.
Publication registry performansı etkiler mi?
Her yayın bir INSERT ve tamamlanınca bir UPDATE/DELETE demektir; sipariş gibi iş olaylarında bu maliyet ihmal edilebilir düzeydedir. Saniyede binlerce olay üreten teknik olaylar için registry yerine düz Spring olayı ya da doğrudan mesaj kuyruğu tercih edilir.
Modül sayısı kaç olmalı?
İş yetenekleri kadar; tipik kurumsal uygulamada 6–15 modül. Yirmiyi geçen sayılar, modüllerin iş yeteneği yerine teknik katmanlara göre bölündüğünün işaretidir.
Sonuç
Modüler monolit, disiplin değil araçla korunur: paket kuralı, verify() testi, publication registry ile güvenilir olaylar, modül bazlı testler ve koddan üretilen dokümantasyon. Bu yapı kurulduğunda hem geliştirme hızı monolit düzeyinde kalır hem de bir modülü servise çıkarmak haftalık bir iş olur.
Mevcut kod tabanınızın modül haritasını çıkarmak ve modülerleştirme yol haritasını belirlemek için yazılım tasarımı ve mimari hizmetimize göz atabilirsiniz. Keşif görüşmesi ve ilk tahmin ücretsizdir.