Kurumsal bir uygulama er ya da geç başka bir sistemle konuşmak zorunda kalır. X firması size camelCase alanlardan oluşan bir JSON döner, Y firmasının gönderdiği şey ise geçen seneden kalma bir XML olabilir. Bir üçüncü firma ise size herhangi bir şey vermeden önce session token ister. Bunların hiçbiri sizin iş mantığınız değildir — ama gerekli önlemleri almazsanız hepsi dönüp dolaşıp iş mantığınızın içine girer.
Hedef söylemesi kolay, koruması ise daha zor bir şey: uygulamanın çekirdeği tek bir modeli bilmeli, sadece onu. Dış dünya hangi formatta gelirse gelsin, dönüşüm sınırda yapılır.
Bu yazıda bu işi birlikte yapan iki deseni Spring Boot tarafında ele alıyorum: farklılıkları gizlemek için Adapter, hangi adaptörün kullanılacağına karar vermek için ise Strategy.
Örneklerde menü servisi kullanıyorum, çünkü payload’ları kısa tutuyor. “Menü” yerine ödeme sağlayıcısı, KYC firması, kargo takibi veya SMS gateway koyabilirsiniz; yapının kendisi değişmez.
Problemin şekli
graph LR
C[Controller] --> UC["Use case"]
UC -->|MenuResponse| P{{MenuAdapter}}
P --> X[XFirmMenuAdapter]
P --> Y[YFirmMenuAdapter]
X -->|"X firması JSON"| XA[(X API)]
Y -->|"Y firması XML"| YA[(Y API)]MenuAdapter‘ın solundaki her şey sizin dilinizi konuşur, sağındaki her şey ise karşı tarafın dilini. Arayüz sınırdır, adaptörler ise o sınırdaki tercümanlardır.
Adapter: tek bir model üzerinde anlaşmak
İçeriden dışarıya doğru ilerleyin. Önce uygulamanızın gerçekten ihtiyaç duyduğu modeli tanımlayın, ardından bu modeli üreten portu oluşturun:
// Sistemin iç dünyasında bilinen tek menü modeli
public record MenuResponse(String menuId, List<String> items) {}
// Her adaptörün uygulayacağı port
public interface MenuAdapter {
MenuResponse fetchMenu();
}
Burada dikkat edilmesi gereken iki nokta var, çünkü işin özü aslında bunlar:
MenuResponseiçinde, sırf X firması gönderiyor diye eklenmiş tek bir alan bile yok. X firma kırk alan gönderiyor olabilir; sizin sadece ikisine ihtiyacınız varsa record iki alanlıdır.- Arayüz entegrasyonun değil, domain’in yanında durur. Adaptörler sizin soyutlamanıza bağımlıdır; tersi değil.
Adaptör de yaptığı iş yalnızca çeviri olan küçük ve sıkıcı bir sınıftır:
@Service
public class XFirmMenuAdapter implements MenuAdapter {
@Override
public MenuResponse fetchMenu() {
return new MenuResponse("X-123", List.of("Kebap", "Ayran"));
}
}
Gerçek bir entegrasyonda bunun içinde bir HTTP çağrısı ve ardından bir mapping olur. Yine de sınıfın sıkıcı kalması iyi bir şeydir — hatta olması gereken tam olarak budur. X firması kontratını değiştirdiğinde değişmesi gereken yer tek bir dosya olur.
Strategy: peki hangi adaptör çalışacak?
Asıl soru burada başlıyor. Elinizde iki adaptör ve tek bir arayüz var; bir şekilde hangisinin kullanılacağına karar vermeniz gerekiyor. Bunun doğru cevabı uygulamanın nasıl deploy edildiğine bağlı. Ve burada birbirinden gerçekten farklı iki senaryo var.
Single-tenant
Her müşteri kendi instance’ına sahip — ayrı sunucu, ayrı container, ayrı konfigürasyon.
Seçim process’in ömrü boyunca değişmeyeceği için açılışta yapılır.
Multi-tenant
Tek bir deployment herkese hizmet verir ve aynı anda gelen iki istek farklı firmalara ait olabilir.
Seçim istek bazında değişebileceği için çalışma zamanında yapılır.
Senaryo A — single-tenant, açılışta seçim
Deployment müşteriye özelse çalışma zamanında seçim yapmaya gerek yoktur. @ConditionalOnProperty kullanarak Spring’in yalnızca o instance’ın ihtiyaç duyduğu adaptörü yüklemesini sağlayabilirsiniz:
// Yalnızca application.yml'da "integration.firm: X" yazıyorsa yaratılır
@Service
@ConditionalOnProperty(name = "integration.firm", havingValue = "X")
public class XFirmMenuAdapter implements MenuAdapter {
@Override
public MenuResponse fetchMenu() {
return new MenuResponse("X-123", List.of("Kebap", "Ayran"));
}
}
// Yalnızca application.yml'da "integration.firm: Y" yazıyorsa yaratılır
@Service
@ConditionalOnProperty(name = "integration.firm", havingValue = "Y")
public class YFirmMenuAdapter implements MenuAdapter {
@Override
public MenuResponse fetchMenu() {
return new MenuResponse("Y-999", List.of("Pizza", "Kola"));
}
}
Konfigürasyon tek satırdan ibaret ve firmanın adı geçen tek yer de burası:
integration:
firm: X
integration:
firm: Y
java -jar menu-service.jar --integration.firm=Y
Controller hangi adaptörü aldığını hiç bilmez:
@RestController
public class MenuController {
private final MenuAdapter menuAdapter;
public MenuController(MenuAdapter menuAdapter) {
this.menuAdapter = menuAdapter;
}
// ... endpoint metotları
}
Desenin asıl değerini tam burada görüyorsunuz. MenuController, X veya Y firmasının varlığından haberdar olmadan çalışır.
Production'a çıkmadan bilinmesi gereken iki hata durumu
integration.firmyoksa veya tanımsız bir değer taşıyorsa hiçbirMenuAdapterbean’i oluşturulmaz ve contextNoSuchBeanDefinitionExceptionile ayağa kalkamaz. Bu gürültülü ve erken bir hatadır; yani aslında tam istediğiniz şey — yeter ki bunu sabahın üçünde production’da değil, CI aşamasında görün.İki şart aynı anda sağlanabiliyorsa bu kez
NoUniqueBeanDefinitionExceptionalırsınız. Property’yi tek değerli tutun veya@Primarykullanın.
Senaryo B — multi-tenant, istek bazında seçim
Şimdi SaaS tarafına geçelim. Tek deployment, birden fazla müşteri ve hangi firmanın kullanılacağı istekle birlikte geliyor: bir header, JWT claim’i veya subdomain üzerinden.
Burada @ConditionalOnProperty işinize yaramaz; tüm adaptörlerin aynı anda context’te bulunması ve hangi adaptörün kullanılacağının her çağrıda belirlenmesi gerekir.
Önce portumuza bir tanımlayıcı ekleyelim:
public interface MenuAdapter {
String getFirmName(); // hangi firmayı temsil ettiği
MenuResponse fetchMenu();
}
Ardından adaptörleri herhangi bir koşul olmadan kaydedelim:
@Service
public class XFirmMenuAdapter implements MenuAdapter {
@Override public String getFirmName() { return "X"; }
@Override public MenuResponse fetchMenu() { return new MenuResponse("X-123", List.of("Kebap")); }
}
@Service
public class YFirmMenuAdapter implements MenuAdapter {
@Override public String getFirmName() { return "Y"; }
@Override public MenuResponse fetchMenu() { return new MenuResponse("Y-999", List.of("Pizza")); }
}
Bu yapıyı kullanışlı hale getiren, Spring’in sık gözden kaçan bir özelliği: List<MenuAdapter> istediğinizde Spring bulduğu tüm implementasyonları size enjekte eder. Bunları constructor’da bir kez indeksleyebilirsiniz:
| |
Map açılışta bir kez oluşturuluyor ve sonrasında değiştirilmiyor. Dolayısıyla lookup maliyeti yok denecek kadar az ve ayrıca senkronize edilmesi gereken bir yapı da kalmıyor.
Bu constructor'da yapacağım tek iyileştirme
HashMap.put aynı anahtar geldiğinde sessizce önceki değerin üzerine yazar. Biri adaptörü kopyalayıp getFirmName() değerini değiştirmeyi unutursa adaptörlerden biri sessizce kaybolur ve bunu production’da fark edersiniz.
Merge fonksiyonu verilmemiş Collectors.toMap, aynı anahtarla karşılaştığında exception fırlatır. Böylece sessiz bir hatayı, uygulamanın daha açılışta ayağa kalkmamasına dönüştürmüş olursunuz:
public MenuStrategyFactory(List<MenuAdapter> adapters) {
this.adapterMap = adapters.stream()
.collect(Collectors.toUnmodifiableMap(
MenuAdapter::getFirmName,
Function.identity()));
}
Yukarıdaki NoSuchBeanDefinitionException örneğinde olduğu gibi burada da amaç aynı: istek geldiğinde değil, uygulama açılırken hata vermek.
Controller tenant’ı okur ve factory’den ilgili adaptörü ister:
@RestController
@RequestMapping("/api/menu")
public class MenuController {
private final MenuStrategyFactory strategyFactory;
public MenuController(MenuStrategyFactory strategyFactory) {
this.strategyFactory = strategyFactory;
}
@GetMapping
public MenuResponse getMenu(@RequestHeader("X-Tenant-ID") String tenantId) {
// Gelen id "X" ise X adaptörü, "Y" ise Y adaptörü çalışır
MenuAdapter adapter = strategyFactory.getAdapter(tenantId);
return adapter.fetchMenu();
}
}
Uçtan uca tek bir istek şöyle ilerler:
sequenceDiagram
participant C as İstemci
participant Ctl as MenuController
participant F as MenuStrategyFactory
participant A as YFirmMenuAdapter
C->>Ctl: GET /api/menu (X-Tenant-ID: Y)
Ctl->>F: getAdapter("Y")
F-->>Ctl: YFirmMenuAdapter
Ctl->>A: fetchMenu()
A-->>Ctl: MenuResponse
Ctl-->>C: 200 + MenuResponseTenant’ı doğrudan header’dan okumak örneği kısa tutmak için kullandım. Gerçek bir serviste bu bilgi genellikle doğrulanmış bir claim’den gelir ve request-scope’lu bir TenantContext içine yazılır — aksi halde header’ı değiştiren herkes istediği tenant’ı seçebilir.
Üçüncü bir firma eklemek
Bir yapının gerçekten iyi olup olmadığını büyürken anlarsınız. Z firması pazartesi günü sözleşmeyi imzaladı:
Adaptörü yaz
ZFirmMenuAdapter implements MenuAdapter, Z’nin payload’ınıMenuResponse‘a çevirir. Tek yeni dosya.Kaydet
Multi-tenant için
@Service; single-tenant için@Serviceartı@ConditionalOnProperty(havingValue = "Z").Başka bir şey yapma
Factory onu enjekte edilen
Listüzerinden kendisi bulur. Neswitch, ne güncellenmesi gereken bir registry, ne de controller’da birif.Domain’e dokunma
MenuResponse, use case’ler ve controller değişmez. İçlerinden biri değişmek zorunda kalıyorsa, soyutlama sızıyor demektir.
Karar matrisi
| Mimari gereksinim | Çözüm | Avantajı |
|---|---|---|
| İzole dağıtım / single-tenant — müşteriye özel sunucu veya pod kurulumu var | @ConditionalOnProperty (açılışta seçim) | Context’i gereksiz yere doldurmaz; kullanılmayan sınıflar hiç bean olarak oluşturulmaz |
| SaaS / multi-tenant — tek uygulama tüm müşterilere hizmet veriyor | Strategy factory (çalışma zamanında seçim) | Header, claim veya subdomain’e göre istek bazında strateji değiştirmeye izin verir |
İki satırda da aynı adaptörleri kullanıyoruz. Bu bilinçli bir tercih. Single-tenant başlayıp daha sonra multi-tenant’a geçerseniz değişecek olan şey anotasyon ve factory olur; entegrasyon kodu değil.
Bunun testlerde karşılığı
Kolayca gözden kaçan kısım burası. Use case yalnızca MenuAdapter‘a bağlı olduğu için onu test etmek için ne HTTP’ye, ne WireMock’a, ne de ağa ihtiyacınız var:
MenuAdapter fake = () -> new MenuResponse("TEST-1", List.of("Kebap"));
Tek metotlu bir arayüz aynı zamanda lambda olarak kullanılabilir. Adaptörlerin kendisi yine gerçek sağlayıcıya karşı entegrasyon testini hak eder; ancak asıl önemli hataların bulunduğu iş mantığı milisaniyeler içinde test edilebilir.
Altta yatan kural
Controller’larınız ve use case’leriniz X, Y, Z firmalarının varlığından haberdar olmamalı. Bildikleri tek şey
MenuResponseolmalı.
Yukarıdaki her şey bu tek cümlenin hizmetinde. Bir kod parçasının adaptöre mi yoksa domain’e mi ait olduğundan emin olamadığınızda kendinize şunu sorun: sağlayıcı yarın değişse hangi tarafın değişmesi gerekirdi? Cevap çoğu zaman doğru sınırı gösterir.

Yorumlar