Yıllarca farklı dillerle projeler geliştirdim. Ancak iş yüksek trafikli bir sisteme (örneğin Betco altyapısı gibi) geldiğinde, kodun sadece “çalışması” yeterli olmuyor. Bu ölçekte neden özellikle Go’yu tercih ettiğimi Neden Golang yazımda ayrıntılarıyla ele almıştım.
Sorun Neydi?
Eğer veritabanı sorgularınızı, iş mantığınızı (business logic) ve HTTP handler’larınızı aynı dosyanın içine tıkıştırırsanız, o proje 6 ay sonra patlamaya mahkumdur.
İşte tam bu noktada Clean Architecture devreye giriyor.
Tek Kural: Bağımlılık İçe Doğru Akar
Clean Architecture’ı yüzlerce kuralın olduğu karmaşık bir tapınak gibi düşünmeyin. Aslında tek bir altın kurala dayanır: Bağımlılık Kuralı (The Dependency Rule).
Kod bağımlılıkları her zaman dıştan içe doğru işaret etmelidir. Yani en değerli katmanınız olan iş mantığınız (domain), veritabanının PostgreSQL mi MySQL mi olduğunu, isteğin HTTP’den mi yoksa bir gRPC çağrısından mı geldiğini bilmemelidir.
- İç katman (Domain): Uygulamanızın kalbi. Hiçbir şeye bağımlı değildir.
- Dış katman (Detaylar): Veritabanı, web framework’ü, üçüncü parti servisler. Bunlar birer “detaydır” ve değiştirilebilir olmalıdır.
Production Notu: Bir framework veya veritabanı, projenizin patronu değil, misafiridir. Bugün GORM kullanıyor olabilirsiniz ama yarın performans için
sqlx’e geçmek istediğinizde, iş mantığınızın tek satırına bile dokunmak zorunda kalmamalısınız. Clean Architecture size bu özgürlüğü verir.
Golang’da Klasör Yapısı
Benim standart olarak kullandığım yapı şudur:
// cmd/api/main.go
package main
import "fmt"
func main() {
fmt.Println("Sistemi ayağa kaldırıyoruz...")
// Burada sadece dependency injection ve server start işlemleri olur.
}
Dosya ağacına baktığımızda ise mimari niyet nettir:
.
├── cmd/
│ └── api/
│ └── main.go # Giriş noktası: sadece bağımlılıkları birleştirir
├── internal/
│ ├── domain/ # Entity'ler + Repository sözleşmeleri (en iç katman)
│ ├── usecase/ # İş mantığı (business logic)
│ ├── repository/ # Veritabanı implementasyonu (GORM vb.)
│ └── delivery/
│ └── http/ # HTTP handler'lar (Gin / Echo / net/http)
└── go.mod
Şimdi bu katmanları tek tek, gerçek kod üzerinden inceleyelim.
1. Domain: Uygulamanın Kalbi
Domain katmanı, iş nesnelerinizi (entity) ve bu nesnelerle ilgili sözleşmeleri (interface) barındırır. Burada import satırlarında gorm veya gin görmeyi beklemeyin; sadece saf Go.
// internal/domain/user.go
package domain
type User struct {
ID int64
Email string
Name string
}
// Repository, dış dünyayla (veritabanı) konuşmanın sözleşmesidir.
// DİKKAT: Bu interface domain katmanında yaşar; implementasyonu burada DEĞİLDİR.
type UserRepository interface {
GetByID(id int64) (*User, error)
Create(user *User) error
}
İşte sihir burada: Repository’nin ne yapacağını domain tanımlar, ama nasıl yapacağını bilmez. Bu, “Dependency Inversion” (Bağımlılığın Ters Çevrilmesi) prensibidir.
Mühendislik Notu: Veritabanı tablolarınızdan bu domain struct’larını elle yazmak, onlarca kolonlu tablolarda hem sıkıcı hem de hataya açıktır. Bu işi otomatize etmek için hazırladığım SQL to GORM Model Converter aracıyla SQL şemanızı saniyeler içinde Go modellerine çevirebilirsiniz.
2. UseCase: İş Kurallarının Yaşadığı Yer
UseCase (bazı ekiplerde “service” olarak da geçer), uygulamanızın gerçekte ne yaptığını anlatan katmandır. Somut bir veritabanına değil, domain’deki interface’e bağımlıdır.
// internal/usecase/user_service.go
package usecase
import "myapp/internal/domain"
type UserService struct {
repo domain.UserRepository // Somut tipe değil, interface'e bağımlıyız!
}
func NewUserService(repo domain.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Register(email, name string) (*domain.User, error) {
// İş kuralları burada yaşar: validasyon, benzersizlik kontrolü, olay yayınlama...
user := &domain.User{Email: email, Name: name}
if err := s.repo.Create(user); err != nil {
return nil, err
}
return user, nil
}
UserService’in bir PostgreSQL veya MongoDB’nin varlığından haberi bile yok. Onun tek bildiği, kendisine verilen UserRepository sözleşmesini yerine getiren “bir şey”.
3. Repository: Değiştirilebilir Detay
Şimdi sözleşmeyi somutlaştırıyoruz. GORM implementasyonu, en dış katmanda yaşar ve domain’e bağımlıdır — tam tersi asla olmaz.
// internal/repository/user_gorm.go
package repository
import (
"gorm.io/gorm"
"myapp/internal/domain"
)
type userGormRepo struct {
db *gorm.DB
}
// Dönüş tipine dikkat: somut struct değil, domain'in interface'i.
func NewUserGormRepo(db *gorm.DB) domain.UserRepository {
return &userGormRepo{db: db}
}
func (r *userGormRepo) Create(user *domain.User) error {
return r.db.Create(user).Error
}
func (r *userGormRepo) GetByID(id int64) (*domain.User, error) {
var user domain.User
if err := r.db.First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
4. Bağımlılıkları Birleştirmek
Peki bu bağımsız katmanlar birbirini nasıl buluyor? İşte main.go’nun tek görevi budur: parçaları birleştirmek (dependency injection).
// cmd/api/main.go
func main() {
db := initDatabase() // *gorm.DB döner
// Dıştan içe doğru enjeksiyon:
userRepo := repository.NewUserGormRepo(db) // detay
userService := usecase.NewUserService(userRepo) // iş mantığı
userHandler := delivery.NewUserHandler(userService) // giriş kapısı
router := setupRouter(userHandler)
router.Run(":8080")
}
Gördüğünüz gibi main.go, her katmanı tanıyan tek yerdir. İç katmanların hiçbiri birbirini somut olarak tanımaz; hepsi interface’ler üzerinden konuşur.
Peki Bu Zahmete Değer mi?
Dürüst olalım: küçük bir CRUD API’si için bu yapı fazla gelebilir. Ancak ekip büyüdükçe ve sistem yıllara yayıldıkça kazanımlar netleşir:
- Test Edilebilirlik:
UserService’i test ederken gerçek bir veritabanına ihtiyacınız yok.UserRepositoryinterface’inin sahte (mock) bir implementasyonunu verirsiniz, iş mantığını milisaniyeler içinde test edersiniz. - Değiştirilebilirlik: Veritabanını, framework’ü veya cache katmanını değiştirmek, sadece ilgili “detay” dosyasını değiştirmek demektir. İş mantığınız donmuş ve güvende kalır.
- Okunabilirlik: Yeni bir geliştirici projeye katıldığında, “iş kuralı nerede?” sorusunun cevabı bellidir:
usecase/. Spagetti kodda ise bu cevap “her yerde ve hiçbir yerde”dir.
Production Notu: Mimariyi bir din gibi değil, bir araç gibi kullanın. Amaç UML diyagramlarını mükemmelleştirmek değil, altı ay sonra gece yarısı gelen bir hata bildiriminde kodun içinde kaybolmamaktır. Katmanları projenizin gerçek karmaşıklığına göre ölçekleyin.
Sonuç
Clean Architecture, Go’nun sadelik felsefesiyle şaşırtıcı derecede uyumludur. Go’nun interface yapısı, tam da bu bağımlılık tersine çevirme prensibi için biçilmiş kaftandır. “Kod çalışıyor” ile “kod sürdürülebilir” arasındaki fark, işte bu katmanları ayırma disiplininde saklıdır.
Bir sonraki projenizde her şeyi main.go’ya doldurmadan önce bir dakika durun ve sorun: “Bu kodu altı ay sonra ben mi okuyacağım, yoksa benim yerime gelen mühendis mi?” Cevap ikisi de olduğunda, Clean Architecture bir tercih değil, bir sorumluluk haline gelir.