Bu seri boyunca birlikte mimari kurduk: Clean Architecture, repository pattern, ports & adapters. Şimdi serinin belki de en önemli bölümüne geldik: ne zaman durmak gerektiği. Çünkü ben bir keresinde tüm bu araçları alıp öyle bir projeye çevirdim ki, repository ağacında büyüleyici görünüyordu ama her gün içinde çalışmak işkenceydi.
Başta gurur duyuyordum. Yapı “ciddi” görünüyordu. handler, usecase, service, repository, entity, dto, mapper, factory, provider… ve önemli isimler taşıyan ama neredeyse hiçbir şey yapmayan birkaç paket daha. Her bağımlılığın bir interface’i, her isteğin bir DTO’su, her yanıtın başka bir DTO’su, her model dönüşümünün bir mapper’ı vardı. Ben “clean architecture” yazdığımı sanıyordum. Gerçekteyse bir labirent inşa ediyordum.
Beni Yavaşlatan Yapı
Kod çalışıyordu. Ama her küçük özellik çok fazla dosya gerektiriyordu. Bir API yanıtına tek bir alan eklemek; handler’a, DTO’ya, mapper’a, usecase’e, service’e, repository’ye ve bazen de sırf çok erken bir interface tanımladığım için var olan bir test mock’una dokunmak demekti.
İşte o acı dersi orada öğrendim: Clean Architecture çok sayıda katmana sahip olmak değildir. Değişimi kolaylaştırmakla ilgilidir. Benim projem tam tersini yapıyordu.
İlk sürüm kabaca şöyle görünüyordu:
/internal
/delivery/http
/usecase
/service
/repository
/entity
/dto
/mapper
/factory
/provider
Büyük ve karmaşık bir domain için bu yapı işe yarayabilir. Ama benim servisim yalnızca birkaç sipariş akışını yönetiyordu: girişi doğrula, idempotency kontrol et, veriyi kaydet, yanıt döndür. Bunu ifade etmek için sekiz katmana ihtiyacım yoktu.
En büyük hatam, gerçek bir sebep yokken interface üretmekti. Her yerde şöyle interface’ler vardı:
type OrderServiceInterface interface {
CreateOrder(ctx context.Context, req CreateOrderRequest) (CreateOrderResponse, error)
}
Ve bunun tek bir implementasyonu vardı. Repository için başka bir interface, entity için başka bir mapper, istek için başka bir DTO, hepsini birbirine bağlamak için başka bir factory… Esnek görünüyordu ama bu esnekliğin çoğu hayaliydi.
Production Notu: Repository pattern yazımda “interface bloat”tan bahsetmiştim: tek implementasyonu olan, hiçbir test faydası sağlamayan bir interface, esneklik değil, sadece gürültüdür. Go’nun kendi felsefesi de bunu söyler — interface’i tüketen (
consumer) tanımlar, üreten değil.
Şimdi Kullandığım Kural
Mimariyi hâlâ önemsiyorum ama artık daha az soyutlamayla başlıyorum. Kuralım basit:
Bir interface’i, implementasyon var diye değil, onu çağıran taraf gerçekten ihtiyaç duyduğunda oluştur.
Bu küçük değişiklik Go yazma şeklimi baştan aşağı değiştirdi. Her struct için bir interface üretmek yerine, somut tipleri; test, birden fazla implementasyon veya sınır ayrımı (boundary separation) bir interface’i gerçekten faydalı kılana kadar koruyorum. Aynı nesnenin beş farklı “shape”i arasında — aralarında gerçek bir fark yoksa — dönüşüm yapmayı da bıraktım.
Daha Yalın Ama Hâlâ Sağlam Bir Örnek
İşte tek dosyaya sığan, daha sade bir Go servisi. Hâlâ ayrım, context yönetimi, idempotency ve test edilebilir sınırlar içeriyor — ama gereksiz katmanlardan kaçınıyor. Kritik nokta: interface yalnızca depolama sınırında var, çünkü test ve gelecekteki değişim için değeri gerçek olan tek yer orası.
// Depolama sınırı — interface'in gerçekten işe yaradığı tek yer
type OrderStore interface {
Save(ctx context.Context, key string, order Order) (Order, bool, error)
}
type OrderService struct {
store OrderStore
now func() time.Time
}
func (s *OrderService) Create(ctx context.Context, key string, req CreateOrderRequest) (Order, bool, error) {
if key == "" {
return Order{}, false, errors.New("idempotency anahtarı eksik")
}
if req.CustomerID == "" || req.ProductID == "" || req.Quantity <= 0 {
return Order{}, false, errors.New("geçersiz sipariş isteği")
}
order := Order{
ID: "ord_" + key,
CustomerID: req.CustomerID,
ProductID: req.ProductID,
Quantity: req.Quantity,
Status: "created",
CreatedAt: s.now(),
}
return s.store.Save(ctx, key, order)
}
Handler ise HTTP’yi iş mantığından ayırıyor, context timeout’u ekliyor ve idempotency anahtarını başlıktan okuyor — ama store’un nasıl çalıştığını bilmiyor:
func createOrderHandler(service *OrderService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "geçersiz json"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond)
defer cancel()
order, duplicated, err := service.Create(ctx, r.Header.Get("Idempotency-Key"), req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
status := http.StatusCreated
if duplicated {
status = http.StatusOK
}
writeJSON(w, status, map[string]any{"duplicated": duplicated, "order": order})
}
}
Bu örnek bilinçli olarak küçük, ama hâlâ gerçek backend endişelerini barındırıyor: doğrulama, context timeout, idempotency, depolama sınırında bir interface ve store’un nasıl çalıştığını bilmeyen bir handler. Bu problem için yeterli mimari budur.
Sildiğim Şeyler
En büyük iyileşme, kod silmekten geldi. Şunları kaldırdım:
- Aynı isimli alanları bir yerden bir yere kopyalamaktan başka bir şey yapmayan
mapperpaketleri. - Tek implementasyonu olan ve hiçbir test faydası bulunmayan interface’ler.
- Tek bir net istek tipi yeterliyken ürettiğim
RequestDTO,UsecaseRequest,ServiceInput,RepositoryParamsgibi tip kalabalığı.
Ve her servisin aynı yapıya ihtiyacı varmış gibi davranmayı bıraktım. Bazı Go servisleri zengin bir domain katmanı ister; bazıları basit bir handler, service ve depolama sınırıyla yetinir; bazı iç araçlar ise temiz fonksiyonlardan oluşan tek bir paket olarak çok daha iyidir. Mimari problemi takip etmeli, problem mimariyi değil.
Mühendislik Notu: DTO ve struct kalabalığından kaçınmak, elle dönüşüm yazmayı da azaltır. Gerçekten farklı bir “shape” gerektiğinde ise struct’ları JSON veya SQL şemanızdan otomatik üretmek için Golang Struct Generator ve Advanced JSON Formatter araçlarımı kullanabilirsiniz — böylece kalan az sayıdaki dönüşüm de elle hata yapmaya açık olmaz.
Koruduğum Şeyler
Projeyi sadeleştirmek, mimariyi çöpe atmak anlamına gelmedi. Hâlâ:
- HTTP handler’larını iş mantığından ayırıyorum.
- İstek sınırları boyunca
context.Contextgeçiriyorum. - Test veya gelecekteki değişim işe yarayacaksa, veritabanı erişimini küçük bir sınırın ardında izole ediyorum.
- Klasör adları etrafında değil, davranış etrafında test yazıyorum.
Fark şu ki, artık sadece projeyi “olgun göstermek” için soyutlama üretmiyorum. İyi Go kodu çoğu zaman sıkıcı hissettirir: isimler nettir, bağımlılıklar görünürdür, hata yolu takip etmesi kolaydır, paket yapısı kimseyi etkilemeye çalışmaz. Bu tür sıkıcı kod, production’da gösterişli mimari diyagramlarından çok daha iyi hayatta kalır.
Yeni Katman Eklemeden Önceki Kontrol Listem
Yeni bir katman veya interface eklemeden önce kendime şunları soruyorum:
- Bu soyutlama gerçek bir karmaşıklığı azaltıyor mu?
- Birden fazla implementasyonum var mı?
- Bu, davranışı gizlemeden testi kolaylaştırıyor mu?
- Yeni bir geliştirici bu paketi hızlıca anlar mı?
- Bugünün problemini mi çözüyorum, yoksa hayali bir gelecekten mi korunuyorum?
Cevap net değilse, kodu basit tutarım.
Son Söz
Projemi aşırı mühendislik ettim, çünkü yapıyı kaliteyle karıştırdım. Dışarıdan temiz görünüyordu ama günlük geliştirme deneyimi başka bir hikâye anlatıyordu: her küçük değişiklik olması gerekenden yavaştı, her katman aramak için başka bir yer, dönüştürmek için başka bir tip ve bakımını yapmak için başka bir mock ekliyordu.
Artık pratik mimariyi tercih ediyorum. Basit başla. Sınırları, kod onları istediğinde ekle. Interface’leri değer ürettikleri yerde kullan. Paketlerin, başka bir projeden kopyalanmış bir şablondan değil, gerçek davranıştan büyümesine izin ver.
Go’da Clean Architecture kaç klasörünüz olduğuyla ilgili değildir. Bir sonraki değişikliğin daha kolay yapılıp yapılamayacağıyla ilgilidir. Bu seriyi buraya kadar okuduysanız, artık hem mimariyi kurmayı hem de ne zaman durmanız gerektiğini biliyorsunuz. İkincisi, çoğu zaman daha zor olanıdır.