Çok sayıda Go servisi yazdım. Küçük olanları, devasa olanları, temiz başlayıp altı ay sonra utandığım bir hale gelenleri… Ve hepsinde en çok tartışmaya, en çok yeniden yazmaya (rewrite) ve en çok gece yarısı mesajlaşmasına sebep olan konu hep aynıydı:
Veri erişim katmanını (data access layer) nasıl yapılandırıyoruz?
Kulağa sıkıcı geliyor ama değil. Yanlış kurarsanız, bir sorguya yeni bir filtre eklemek için altı dosyaya dokunmanız gereken bir kod tabanıyla baş başa kalırsınız. Doğru kurarsanız, veri katmanınız tüm sistemin en sıkıcı ve en güvenilir parçası olur — ki tam da istediğiniz şey budur.
Bu yazı, Clean Architecture yazımda değindiğim repository katmanının derinlemesine bir devamı. Yıllar içinde denediğim beş yaklaşımı ve neden sadece birinin her projede ayakta kaldığını anlatacağım.
Pattern 1: Metotlu Struct (Acemi Tercihi)
Her Go geliştiricisi buradan başlar. Bir veritabanı bağlantısını tutan bir struct oluşturur ve ihtiyaç duyduğunuz her sorgu için ona bir metot eklersiniz.
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
var u User
err := r.db.QueryRowContext(ctx,
"SELECT id, name, email FROM users WHERE id = $1", id,
).Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return nil, fmt.Errorf("kullanıcı bulunamadı: %w", err)
}
return &u, nil
}
Temiz, basit, deyimsel (idiomatic) Go. Birkaç sorgusu olan küçük servisler için mükemmel çalışır.
Sorun, büyüyen bir projenin üçüncü haftası civarında ortaya çıkar. Her yeni filtre ihtiyacı yeni bir metot ekler: FindByStatus, FindByStatusAndRole, FindActiveCreatedAfter… Struct büyür, dosya büyür ve bir gün UserRepository’yi açtığınızda kırk yedi metodu olan, kimsenin içinde bir şey bulamadığı bir canavara dönüşmüştür.
Karar: Harika bir başlangıç. Ama karmaşıklıkla birlikte ölçeklenmez.
Pattern 2: Önce Interface (Kurumsal Tercih)
Çoğu ekibin ulaştığı bir sonraki durak, önce bir interface tanımlayıp sonra onu implemente etmektir. “Go’da Clean Architecture” başlıklı her blog yazısında gördüğünüz kalıp budur.
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Save(ctx context.Context, u *User) error
}
type postgresUserRepository struct {
db *sql.DB
}
func NewPostgresUserRepository(db *sql.DB) UserRepository {
return &postgresUserRepository{db: db}
}
Faydası test edilebilirliktir. Interface’in bellek-içi (in-memory) bir implementasyonunu yazıp, gerçek veritabanına ihtiyaç duymadan birim testi yapabilirsiniz:
type inMemoryUserRepository struct {
users map[int64]*User
}
func (r *inMemoryUserRepository) FindByID(ctx context.Context, id int64) (*User, error) {
u, ok := r.users[id]
if !ok {
return nil, sql.ErrNoRows
}
return u, nil
}
Bu gerçekten değerli. Ama “önce interface” yaklaşımının zamanla ortaya çıkan gizli bir bedeli var: interface bloat (interface şişmesi). Her yeni sorgu ihtiyacı interface’e eklenir. Interface yirmi metoda ulaşır. Artık her sahte (mock) implementasyon, sadece iki metotla ilgilense bile yirmi metodun hepsini yazmak zorundadır.
Go’nun kendi standart kütüphanesi bu dersi zor yoldan öğrenmiştir. io paketi tek bir dev io.FileSystemOperator yerine minik, odaklı arayüzler tanımlar: io.Reader, io.Writer, io.Closer. Bu felsefeyi standart kütüphane yazımda ayrıntılı ele almıştım; aynı prensip burada da geçerli.
Karar: Test edilebilirlik için Pattern 1’den iyi. Ölçekte interface bloat’tan muzdarip.
Pattern 3: Query Object (Aşırı Mühendislik)
Metot patlamasından yanan bazı ekipler, tam tersi uca savrulur. Her sorgu için bir metot yerine, ne getireceğini tarif eden bir sorgu nesnesi (query object) alan tek bir Find metodu yazarlar.
type UserQuery struct {
ID *int64
Email *string
Status *string
Role *string
Page int
PageSize int
}
type UserRepository interface {
Find(ctx context.Context, q UserQuery) ([]User, error)
Save(ctx context.Context, u *User) error
}
İmplementasyon, hangi alanların dolu olduğuna göre dinamik bir SQL kurar. Bu, metot patlamasını çözer — tek metot tüm sorguları karşılar. Ama yeni bir sorun doğurur: query object bir çöp tenekesine dönüşür. Her yeni filtre ihtiyacı UserQuery’ye yeni bir alan ekler. Join’ler, alt sorgular, aggregate’ler gibi karmaşık sorgular bu düz struct üzerinden temiz bir şekilde ifade edilemez ve dinamik SQL kurucusu büyüdükçe giderek kırılganlaşır.
Karar: Metot patlamasını çözer ama query object şişmesi yaratır. Karmaşık sorgularda çöker.
Pattern 4: Functional Options (Zekice Tercih)
Go’nun konfigürasyon için kullandığı functional options kalıbından ilham alan bazı geliştiriciler, aynı fikri sorgulara uygular. Bir struct yerine, sorguyu değiştiren opsiyon fonksiyonları geçirirsiniz.
func (r *postgresUserRepository) Find(ctx context.Context, opts ...findOption) ([]User, error) {
options := &findOptions{page: 1, pageSize: 20}
for _, opt := range opts {
opt(options)
}
// opsiyonlara göre sorguyu kur ve çalıştır
}
Kullanımı son derece etkileyicidir:
users, err := repo.Find(ctx,
WithStatus("active"),
WithRole("admin"),
WithPagination(2, 50),
)
Gerçekten zarif; birleştirilebilir (composable), genişletilebilir ve çağrı noktasında harika okunur. Sorun keşfedilebilirlikte (discoverability). Ekibe yeni katılan biri, mevcut tüm WithXxx fonksiyonlarını bulmak için paketin içinde avlanmak zorundadır. Desteklenen filtreleri tek bir yerde göremezsiniz. Ayrıca closure’ları açmadan bir mock’a hangi opsiyonların geçtiğini incelemek zor olduğundan testi de zorlaştırır.
Karar: Zarif ve birleştirilebilir. Zayıf keşfedilebilirlik ve zor mock’lanabilirlik.
Pattern 5: Küçük Interface + Filter Struct (Hayatta Kalan)
Yukarıdaki dördünü de denedikten sonra dönüp dolaşıp geldiğim kalıp bu. Her birinin en iyi fikrini alıp, başarısızlık noktalarından kaçınıyor. Temel fikir basit: interface’i küçük ve domain anlamı taşıyan tut, esnek sorgu gerçekten gerektiği yerde bir filter struct kullan, ve ikisinin de domain’in ihtiyacından fazla büyümesine asla izin verme.
// Interface küçük. Her metodun net bir domain anlamı var.
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Search(ctx context.Context, filter UserFilter) ([]User, int, error)
Save(ctx context.Context, u *User) error
Delete(ctx context.Context, id int64) error
}
// Filter struct YALNIZCA esnek sorgu gereken yerde kullanılır.
// Basit aramalarda (FindByID) kullanılmaz.
type UserFilter struct {
Status string
Role string
Page int
PageSize int
}
Search metodu, dolu olan filtre alanlarına göre koşulları ve sayfalamayı kurar; FindByID ise sadece bir id alır. Doğru araç, doğru yerde. Ve testler için bellek-içi implementasyon hâlâ temiz ve odaklıdır — dört metot, on beş dakikada tam olarak yazılabilir.
func (r *postgresUserRepository) Search(ctx context.Context, f UserFilter) ([]User, int, error) {
conditions := []string{"1=1"}
args := []any{}
i := 1
if f.Status != "" {
conditions = append(conditions, fmt.Sprintf("status = $%d", i))
args = append(args, f.Status)
i++
}
if f.Role != "" {
conditions = append(conditions, fmt.Sprintf("role = $%d", i))
args = append(args, f.Role)
i++
}
where := strings.Join(conditions, " AND ")
// ... COUNT + LIMIT/OFFSET ile sayfalama, ardından rows.Scan
}
Mühendislik Notu: Repository’lerinizin altındaki tablo yapılarını ve GORM modellerini elle yazmak yerine, SQL şemanızdan otomatik üretmek için hazırladığım SQL to GORM Model Converter aracını kullanabilirsiniz. Domain struct’larını JSON’dan üretmek içinse Golang Struct Generator iş görür.
Neden Bu Hayatta Kaldı?
Pattern 5’in birden fazla projede ve ekipte diğerlerini neden geride bıraktığını somutlaştırayım:
- Interface tam olarak implemente edilebilecek kadar küçük. Dört metot. Herhangi bir geliştirici on beş dakikada eksiksiz bir mock yazabilir. Sizi ihtiyacınız olmayan metotları yazmaya zorlayan bir bloat yok.
- Filter struct ait olduğu yere sınırlı.
FindByIDfiltre kullanmaz, biridalır.Searchfiltre kullanır, çünkü arama doğası gereği esnektir. - Keşfedilebilir. Yeni bir geliştirici
UserRepositoryinterface’ini açar ve tüm API yüzeyini anında anlar. Avlanması gereken gizliWithXxxfonksiyonları yoktur. - Veritabanı olmadan test edilebilir. Bellek-içi implementasyon aynı interface’i karşılar; testler milisaniyelerle değil, mikrosaniyelerle ölçülür.
- Zarifçe büyür. Yeni bir sorgu ihtiyacı geldiğinde karar nettir: bilinen bir tanımlayıcıyla basit bir arama mı? Odaklı bir metot ekle. Birden fazla opsiyonel filtreli esnek bir arama mı?
UserFilter’a bir alan ekle.
Benimsediğim Tek Kural
Tüm bunlardan sonra, yazdığım her repository’ye yön veren tek bir kuralım var:
Sorguyu bir domain kavramıyla adlandırabiliyorsan, onu bir metot yap. Adlandıramıyorsan, bir filtre yap.
FindByID bir domain kavramıdır. FindActiveAdminsByRegionCreatedAfterWithPagination ise değildir — o, filtreli bir aramadır. Bu tek kural beni hem metot patlamasından hem de query object şişmesinden kurtardı.
Sonuç
Beş pattern, tek bir hayatta kalan. Diğerleri kötü tasarlandığı için değil — her biri gerçek bir problemi çözüyordu. Ama yalnızca biri, kod tabanı büyüdükçe, ekip değiştikçe ve gereksinimler kimsenin öngörmediği şekilde evrildikçe problem çözmeye devam etti.
En iyi mimari, en zekice olan değildir. Altı ay sonra, verdiğiniz kararların nedenini unuttuğunuzda ve yeni bir geliştirici Cuma günü saat 16:00’da bir özellik eklemeye çalışırken yolunuza çıkmayan mimaridir. Pattern 5 yolunuza çıkmıyor; hayatta kalmasının sebebi de bu.