Yazdığım her Go servisi eninde sonunda dışarıdan bir şeyle konuşmak zorunda kalıyor. Bir ödeme geçidi, bir kargo sağlayıcısı, bir bildirim servisi ya da başka bir ekibin sahibi olduğu iç mikroservis. Dış şeyin ne olduğu fark etmiyor — soru hep aynı: Bu kod nerede yaşamalı ve altı ay sonra sessizce bir çöplüğe dönüşmesin diye onu nasıl yapılandırmalıyım?
Bir önceki yazıda repository pattern’lerini ele alırken veritabanı erişimini konuşmuştuk. Bu yazı onun kardeşi: aynı disiplini veritabanına değil, dış dünyaya uyguluyoruz. Yıllar içinde denediğim üç yaklaşımdan yalnızca biri Clean Architecture’ın bağımlılık kuralını ayakta tutmayı başardı.
Kötü Başlangıç: Servise Gömülü HTTP Çağrıları
Tasarımlara girmeden önce, neyden kaçtığımızı netleştirelim. İşte hiçbir tasarım olmadığında dış API çağrıları böyle görünür:
func (s *OrderService) ConfirmPayment(ctx context.Context, orderID int, amount decimal.Decimal) error {
body, _ := json.Marshal(map[string]any{
"amount": amount.String(),
"order_id": orderID,
"currency": "USD",
})
resp, err := http.Post("https://api.odeme-gecidi.com/v1/charges",
"application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("ödeme onayı: %w", err)
}
defer resp.Body.Close()
// ...
}
Bu çalışır. Ta ki çalışmayana kadar. Bunu gerçek ödeme geçidine istek atmadan test edemezsiniz. Sağlayıcıyı, servis kodunuza dokunmadan değiştiremezsiniz. Retry, timeout veya gözlemlenebilirlik (observability) eklemek bu fonksiyonu daha da kirletir. Ve ödeme geçidi API’sini değiştirdiğinde, onları çağırdığınız her yeri servis dosyaları arasında avlarsınız.
İşte üç tasarımın da çözmeye çalıştığı problem bu.
Tasarım 1: Ham HTTP Client Struct
Çoğu geliştiricinin ilk içgüdüsü, HTTP mantığını kendi struct’ına çıkarmaktır. Tek bir dış servise yapılan tüm çağrıları bir araya toplayın, bir http.Client enjekte edin, bitti.
// internal/payment/client.go
type PaymentClient struct {
baseURL string
httpClient *http.Client
apiKey string
}
func NewPaymentClient(baseURL, apiKey string) *PaymentClient {
return &PaymentClient{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (c *PaymentClient) Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error) {
// JSON marshal, NewRequestWithContext, Authorization header, Do, decode...
}
Bu, naif versiyondan çok daha iyi: HTTP mantığı izole, struct enjekte edilebilir, timeout ve baseURL ortama göre yapılandırılabilir. Standart kütüphanenin http.Client’ını kendi timeout’unuzla kullanmayı standart kütüphane yazımda da vurgulamıştım.
Nerede kırılıyor: Servis katmanınız artık doğrudan *PaymentClient’a — bir iç paketten gelen somut bir tipe — bağımlı. Testte onu bir mock ile değiştiremezsiniz. Ve ödeme sağlayıcısını tamamen değiştirmek isterseniz, iş mantığı hiç değişmese bile servis katmanına dokunmak zorundasınız.
type OrderService struct {
orders domain.OrderRepository
payment *payment.PaymentClient // ← somut bağımlılık
}
Tasarım 2: Her İstemciye Bir Interface
Doğal bir sonraki adım, her dış istemci için bir interface tanımlayıp somut tip yerine interface’e bağımlı olmaktır.
// internal/payment/client.go
type Client interface {
Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error)
Refund(ctx context.Context, chargeID string, amount decimal.Decimal) error
}
Artık servis interface’e bağımlı; testte payment.Client’ı mock’layabilir, implementasyonu değiştirebilirsiniz. Daha iyi. Ama servisiniz büyüdükçe ortaya çıkan bir sorun var: interface ve tipleri, bir altyapı paketi olan payment paketinde yaşıyor. Servis katmanınız altyapıdan import ediyor — ve bu, Clean Architecture’da bir bağımlılık yönü ihlali.
Domain ve uygulama katmanlarınız bir “PaymentClient”ın var olduğunu bilmemeli. Onlar sadece bir müşteriden ödeme almaları gerektiğini bilmeli. Nasıl olduğu altyapının derdidir.
Tasarım 3: Ports & Adapters — Doğru Soyutlama
İşte Clean Architecture’a gerçekten uyan tasarım. Adı Ports & Adapters (namıdiğer Hexagonal Architecture) ve temel içgörü şu: interface, altyapı katmanına değil, domain katmanına aittir. Bir o kadar önemlisi: interface, sağlayıcının değil, işin dilinde ifade edilmelidir.
Bu ince ama kritik bir ayrım. Eğer port’unuz “Charge” kelimesini ve “Currency” alanını kullanıyorsa, Stripe’ın kelime dağarcığını domain’inize sızdırıyorsunuz demektir. İş, müşterileri “charge” etmez — ödeme işler. İş, çağrı noktasında hangi para biriminin kullanılacağına karar vermez — o, adapter’ın sahip olduğu bir konfigürasyon meselesidir.
Temiz bir port şöyle görünür:
// domain/payment.go — sağlayıcının değil, işin dilinde
type PaymentService interface {
Pay(ctx context.Context, p Payment) (ProcessedPayment, error)
Refund(ctx context.Context, referenceID string, amount decimal.Decimal) error
}
type Payment struct {
OrderID int
Amount decimal.Decimal
}
type ProcessedPayment struct {
ReferenceID string
}
Eksik olana dikkat edin: Currency alanı yok, ChargeID yok, sağlayıcıya özgü hiçbir terim yok. Payment sadece şunu söylüyor: “Şu sipariş için şu tutarı işlemem gerek.” Bu bir iş cümlesidir. Adapter’ın para birimini nasıl ele aldığı, hangi endpoint’i çağırdığı, sağlayıcının işlem ID’sine ne dediği — hiçbiri domain’in derdi değildir.
İsimlendirmeye de dikkat edin: PaymentService, PaymentPort değil. “Port” mimari bir terimdir, iş terimi değil. Domain, bir yazılım kalıbı ders kitabı gibi değil, bir iş konuşması gibi okunmalıdır.
Şimdi altyapı katmanındaki adapter bu interface’i implemente eder. Para birimi burada, domain girdisi olarak değil, adapter konfigürasyonu olarak yaşar:
// infrastructure/payment/stripe_adapter.go
type stripeAdapter struct {
baseURL string
currency string // ← adapter konfigürasyonu, domain girdisi değil
httpClient *http.Client
apiKey string
}
func NewStripeAdapter(baseURL, apiKey, currency string) domain.PaymentService {
return &stripeAdapter{ /* ... */ }
}
func (a *stripeAdapter) Pay(ctx context.Context, p domain.Payment) (domain.ProcessedPayment, error) {
reqBody, _ := json.Marshal(stripeChargeRequest{
OrderID: p.OrderID,
Amount: p.Amount.String(),
Currency: a.currency, // ← adapter'dan geliyor
})
// NewRequestWithContext, Authorization: Bearer, Do...
switch resp.StatusCode {
case http.StatusPaymentRequired, http.StatusUnprocessableEntity:
return domain.ProcessedPayment{}, domain.ErrPaymentDeclined
case http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return domain.ProcessedPayment{}, domain.ErrPaymentProviderDown
case http.StatusOK:
// devam et
default:
return domain.ProcessedPayment{}, fmt.Errorf("beklenmeyen durum %d", resp.StatusCode)
}
var result stripeChargeResponse
json.NewDecoder(resp.Body).Decode(&result)
return domain.ProcessedPayment{ReferenceID: result.ChargeID}, nil
}
JSON etiketleri, Stripe’a özgü alan adları, para birimi konfigürasyonu, HTTP bağlantısı — hepsi adapter’ın içinde kilitli. Domain bunların hiçbirini görmez.
Mühendislik Notu: Sağlayıcının JSON yanıtından adapter tiplerinizi (
stripeChargeResponsegibi) hızlıca çıkarmak için hazırladığım Advanced JSON Formatter aracını,Authorization: Bearerbaşlığındaki token’ları incelemek içinse JWT Decoder & Verifier aracını kullanabilirsiniz.
Servis katmanı artık tamamen temiz ve yalnızca iş dilinde konuşuyor:
// application/order_service.go
type OrderService struct {
orders domain.OrderRepository
payment domain.PaymentService // ← yalnızca domain'e bağımlı
}
func (s *OrderService) ConfirmPayment(ctx context.Context, orderID int, amount decimal.Decimal) error {
order, err := s.orders.FindByID(ctx, orderID)
if err != nil {
return err
}
processed, err := s.payment.Pay(ctx, domain.Payment{OrderID: orderID, Amount: amount})
if err != nil {
return err
}
updated, err := order.Pay(processed.ReferenceID)
if err != nil {
return err
}
return s.orders.Update(ctx, updated)
}
Servis yalnızca domain’den import ediyor. Stripe’ın var olduğundan, hangi para biriminin kullanıldığından veya hangi endpoint’in çağrıldığından haberi bile yok. Yarın Stripe’tan başka bir sağlayıcıya geçerseniz, yeni bir adapter yazar, dependency injection bağlantısını güncellersiniz — servis hiç değişmez.
Hataları Doğru Yönetmek
Üç tasarımın da sıklıkla yanlış yaptığı bir şey, adapter sınırındaki hata yönetimidir. Servis katmanınız asla HTTP durum kodlarını incelemek veya sağlayıcıya özgü hata mesajlarını ayrıştırmak zorunda kalmamalı. Bu, adapter’ın işidir.
İş mantığınızın önemsediği sonuçlar için domain hataları tanımlayın:
// domain/errors.go
var (
ErrPaymentDeclined = errors.New("ödeme reddedildi")
ErrPaymentProviderDown = errors.New("ödeme sağlayıcısı erişilemiyor")
ErrPaymentProviderError = errors.New("ödeme sağlayıcısı hatası")
ErrOrderNotPending = errors.New("sipariş beklemede değil")
)
Çeviri zaten yukarıdaki adapter’ın içinde oluyor. Servis artık HTTP hakkında hiçbir şey bilmeden temiz iş kararları verebiliyor:
processed, err := s.payment.Pay(ctx, domain.Payment{OrderID: orderID, Amount: amount})
if errors.Is(err, domain.ErrPaymentDeclined) {
return s.orders.Update(ctx, order) // siparişi 'reddedildi' olarak işaretle
}
if errors.Is(err, domain.ErrPaymentProviderDown) {
return err // geçici hata; retry/kuyruk mekanizması devreye girsin
}
İş mantığında HTTP durum kodu yok, servise sızan Stripe’a özgü hata tipi yok. Sadece işletme için anlam ifade eden domain hataları. (errors.Is ile hata zincirlerini kontrol etmeyi standart kütüphane yazımda ele almıştım.)
Production Notu: Adapter, orijinal hatayı loglamadan doğrudan domain hatasını döndürür; bu bilinçlidir. Production sistemlerinde loglama genellikle
context’ten okuyan merkezi bir katman (middleware) tarafından yapılır:trace_id’yi, istek meta verisini ve tüm hata zincirini otomatik yakalar. Adapter’ın loglama yapmasına gerek yoktur; tek işine — sağlayıcı yanıtlarını domain diline çevirmeye — odaklı kalır.
Gerçek API’ye Dokunmadan Test
Ports & Adapters ile servisinizi test etmek çok basittir. Port’u bir mock ile implemente edersiniz — gerçek HTTP sunucusu, test kimlik bilgisi veya kararsız (flaky) ağ yok:
type mockPaymentService struct {
payFunc func(ctx context.Context, p domain.Payment) (domain.ProcessedPayment, error)
}
func (m *mockPaymentService) Pay(ctx context.Context, p domain.Payment) (domain.ProcessedPayment, error) {
return m.payFunc(ctx, p)
}
func TestConfirmPayment_Declined(t *testing.T) {
svc := NewOrderService(
&mockOrderRepository{ /* Status: Pending döner */ },
&mockPaymentService{
payFunc: func(_ context.Context, _ domain.Payment) (domain.ProcessedPayment, error) {
return domain.ProcessedPayment{}, domain.ErrPaymentDeclined
},
},
)
err := svc.ConfirmPayment(context.Background(), 1, decimal.NewFromFloat(99.99))
assert.ErrorIs(t, err, domain.ErrPaymentDeclined)
}
Saf iş mantığı, izole halde, mikrosaniyeler içinde test edilir.
Sonuç
Kısaca özetleyelim:
- Tasarım 1 (ham HTTP struct): HTTP mantığını izole eder ama mock’laması ve değiştirmesi zor, somut bir bağımlılık yaratır.
- Tasarım 2 (istemci başına interface): Mock’lanabilirlik ekler ama interface’i yanlış pakete koyarak bağımlılık kuralını ihlal eder.
- Tasarım 3 (Ports & Adapters): Interface’i domain katmanına, iş dilinde koyar —
PaymentService,PaymentPortdeğil. Hata çevirisi adapter’a aittir; domain hataları HTTP protokolüne değil, işe anlam ifade eder.
Bu kalıp iyi ölçeklenir. İkinci bir ödeme sağlayıcısı eklediğinizde ikinci bir adapter yazarsınız. Retry veya circuit breaker eklediğinizde adapter’ı sararsınız — servis hiç değişmez. Belirli bir hata senaryosunu test etmek istediğinizde, port’u tam istediğiniz davranışla mock’larsınız.
Dış API çağrıları, Go servislerinde en yaygın bağımlılık (coupling) kaynaklarından biridir. Soyutlamayı en baştan doğru kurmak, verebileceğiniz en yüksek getirili kararlardan biridir.