Yıllardır Go kodu yazıyor ve inceliyorum; kendi projelerimde, ekip arkadaşlarımın PR’larında ve açık kaynak repolarda. Bir süre sonra fark ettim ki, kod tabanı değişse de karşıma çıkan hatalar hep aynı birkaç başlıkta toplanıyor. Bunlar egzotik, derin Go özellikleri değil — tam tersine, en temel kavramlar. Ama yanlış kullanıldıklarında uygulamanızı kırılgan, bakımı zor ve bazen tehlikeli hale getiriyorlar.
Üstelik bu hatalar Go’nun en yeni sürümlerinde bile hâlâ her yerde. Gelin bu beş tuzağı tek tek açalım: neden problem olduklarını ve onları kodunuzdan nasıl söküp atacağınızı.
Hata 1: Hataları Yok Saymak
Go’nun bence en güçlü tasarım kararlarından biri, hataları birer değer olarak ele almasıdır. İstisna (exception) fırlatıp kaçmak yerine, sizi hatayla yüzleşmeye zorlar. Ama en sık gördüğüm hata da tam burada: geliştiriciler ya hatayı tamamen yok sayıyor ya da hiç bağlam eklemeden yukarı fırlatıyor.
Üç yaygın alt hata var:
- Hatayı yutmak:
_boş tanımlayıcısıyla hatayı çöpe atmak. “Bu işlem zaten hata vermez” diye düşündüğünüz an, gözünüz kapalı otoban sürmeye başlamışsınız demektir. - Bağlamı kaybetmek: Sadece
return errdemek, çağıran tarafı hatanın nerede ve neden oluştuğu konusunda kör bırakır. ==yerineerrors.Is: Bir hata%wile sarmalandığında (wrap), doğrudan==karşılaştırması sessizce çalışmaz olur.
Çözüm: kontrol et, bağlamla sarmala, ayırt et. Go’nun errors paketi ve fmt.Errorf’in %w fiili bunun için biçilmiş kaftandır:
var ErrUserNotFound = errors.New("kullanıcı bulunamadı")
func (r *UserRepository) FetchUser(id string) (*User, error) {
if id == "404" {
return nil, ErrUserNotFound // adlandırılmış (sentinel) hata
}
if id == "500" {
// Bağlamla sarmala — hata ayıklamayı kolaylaştırır
return nil, fmt.Errorf("kullanıcı %s için DB bağlantısı: %w", id, errNetworkTimeout)
}
return &User{ID: id}, nil
}
func ProcessUser(repo *UserRepository, id string) error {
user, err := repo.FetchUser(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) { // == değil, errors.Is
log.Printf("bilgi: kullanıcı %s yok, akışa devam", id)
return nil // "bulunamadı" her zaman bir hata değildir
}
return fmt.Errorf("kullanıcı %s işlenemedi: %w", id, err)
}
return nil
}
"database connection failed" yerine "kullanıcı 123 birincil veritabanından çekilemedi: bağlantı zaman aşımına uğradı" demek, gece yarısı gelen bir production hatasını 2 saatte değil 5 dakikada çözmenizi sağlar. errors.Is ve errors.As’i standart kütüphane yazımda ayrıntılı ele almıştım.
Hata 2: Kontrolsüz Goroutine’ler ve Data Race’ler
Go’nun goroutine ve channel’larla gelen eşzamanlılık modeli dilin süper gücüdür — Neden Golang yazımda bu yüzden övmüştüm. Ama bu kolaylık, çok sinsi iki hataya kapı aralar: goroutine sızıntıları ve data race’ler. İkisi de genellikle sistem yük altındayken yüzünü gösterir, bu da onları yakalamayı kâbusa çevirir.
- Goroutine sızıntısı: Durma yolu olmayan bir goroutine başlatmak. Kapanmayan bir channel üzerinde sonsuza dek dönen bir
for range, ya da yanlış artırılmış birWaitGroup… Zamanla bellek dolar ve uygulama çöker. - Data race: Birden fazla goroutine’in, kilit (
Mutex) veya channel olmadan aynı paylaşılan veriye (map, slice, struct alanı) aynı anda yazması. Sonuç belirsizdir (non-deterministic) — veri bozulur. - WaitGroup yanlış kullanımı:
wg.Add()’i goroutine’in içinde çağırmak.wg.Wait(),Add()çalışmadan önce dönebilir ve ana akış işçiler bitmeden çıkar.
Doğrusu: yaşam döngüsünü yönet, erişimi senkronize et:
jobs := make(chan string, 10)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // goroutine'i BAŞLATMADAN ÖNCE Add
go func(workerID int) {
defer wg.Done()
for job := range jobs { // channel kapandığında döngü biter
process(job)
}
}(i)
}
for j := 1; j <= 5; j++ {
jobs <- fmt.Sprintf("job-%d", j)
}
close(jobs) // göndermeyi bitirince channel'ı KAPAT
wg.Wait() // tüm işçileri nazikçe bekle
Paylaşılan bir sayaç gibi durumları ise sync.Mutex ile koruyun (mu.Lock() / defer mu.Unlock()).
Production Notu: Data race’ler belirsiz olduğu için gözle göremezsiniz. Go’nun yerleşik yarış dedektörünü kullanın:
go test -race ./.... Bu komut, CI hattınızın vazgeçilmez bir parçası olmalı — çünkü bir data race’i production’da yakalamak, testte yakalamaktan kat kat pahalıdır.
Hata 3: Context’i Yanlış Yönetmek
context.Context, istek kapsamlı değerleri, iptal sinyallerini ve zaman aşımlarını (deadline) API’ler ve goroutine’ler arasında taşıyan kritik bir araçtır. Ama tutarsız ya da yanlış kullanımı; asılı kalan istekler, temizlenmeyen kaynaklar ve sonsuza dek koşan işlemler doğurur.
nilcontext geçmek:context.Background()veyacontext.TODO()yerine doğrudannilvermek — panik davetiyesi.- Context’i iptal etmemek:
WithCancel,WithTimeout,WithDeadlinebircancelfonksiyonu döndürür.defer cancel()demezseniz, serbest bırakılmayan kaynaklardan bir sızıntı izi bırakırsınız. context.WithValue’yu suistimal etmek: Küçük, istek kapsamlı meta veriler (trace ID gibi) için tasarlanmıştır. Onunla koca argümanlar veya iş nesneleri taşımak kodu okunmaz ve test edilemez kılar.- Context’i struct’ta saklamak: Context geçicidir; bir isteğin ömrü kadar yaşamalıdır. Struct’lara veya global değişkenlere gömmek, bayat context’lerin yeniden kullanılmasına yol açar.
func processRequest(ctx context.Context, requestID string) {
// Türetilmiş context + timeout
opCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // HER ZAMAN defer cancel
if err := performTask(opCtx); err != nil {
log.Printf("istek %s başarısız: %v", requestID, err)
}
}
func performTask(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
return nil // iş bitti
case <-ctx.Done(): // iptal/timeout sinyaline SAYGI göster
return ctx.Err()
}
}
Bu, dış API çağrıları yazımdaki timeout ve iptal disipliniyle birebir aynı prensiptir: her G/Ç işlemi bir context almalı ve onu dinlemeli.
Hata 4: “Tanrı Struct’lar” ve Aşırı Soyutlama
Uygulamalar büyüdükçe, zıt ama eşit derecede zararlı iki tasarım hatası ortaya çıkar: meşhur “Tanrı Struct” (God Struct) ve kuzeni aşırı soyutlama (gereksiz interface bolluğu). İkisi de sıkı bağlı (tightly coupled), test edilmesi kâbus olan kod üretir.
- Tanrı Struct: Her şeyi bilen, birbiriyle alakasız onlarca işi yapan ve bir dağ kadar bağımlılık tutan tek bir struct. Onda küçük bir değişiklik, tüm uygulamayla Jenga oynamak gibidir; tek bir metodu test etmek için yarım uygulamayı mock’lamanız gerekir.
- Interface kirliliği: Her tip için, üstelik çok erken, interface tanımlamak. Bu, gerçek bir esneklik sağlamadan sadece soyutlama katmanı ekler. Go’nun yolu genelde şudur: “interface kabul et, struct döndür” — ve interface’i tüketen (
consumer) tarafta tanımla.
Çözüm: küçük, tek sorumluluklu tipler ve tam ihtiyaç anında (just-in-time) interface’ler. Dev bir GodService yerine, odaklı servisler ve dar interface’ler:
// Dar, odaklı interface — tüketicinin ihtiyacı kadar
type UserDatabase interface {
FetchUser(id string) (string, error)
}
type EmailSender interface {
SendEmail(recipient, subject, body string) error
}
// UserService yalnızca ihtiyacı olanı kabul eder
type UserService struct {
userDB UserDatabase
email EmailSender
}
func NewUserService(db UserDatabase, email EmailSender) *UserService {
return &UserService{userDB: db, email: email}
}
Bu tam olarak aşırı mühendislik yazımda ve repository pattern yazımda savunduğum dengedir: interface’i, var diye değil, çağıran gerçekten ihtiyaç duyunca oluştur.
Mühendislik Notu: Odaklı struct’lar tasarlarken, JSON veya SQL şemanızdan temiz Go modelleri üretmek için Golang Struct Generator ve SQL to GORM Model Converter araçlarımı kullanabilirsiniz — böylece tip tanımlarken elle hata yapmazsınız.
Hata 5: Dağınık Proje Yapısı
Go, proje yapısı konusunda size katı bir düzen dayatmaz. Bu özgürlük güzel, ama düşüncesiz bir organizasyon iyi bir kod tabanını hızla karman çorman bir yumağa çevirir.
- Genel
util/commonpaketleri: Bunlar neredeyse her zaman kod tabanının “çekmecesi” olur — yeri belli olmayan her fonksiyonun atıldığı çöplükler. Go’nun uyumlu, domain odaklı paket felsefesine aykırıdır. - Düz kök yapı / iş mantığını dışa açmak: Tüm paketleri kökte tutarsanız (
auth/,user/…), dışarıdaki her proje onlarıimportedebilir. Bu, kapsüllemeyi (encapsulation) kırar. - Dairesel bağımlılıklar: Kötü paket organizasyonu, Go derleyicisinin reddedeceği dairesel
import’lara giden hızlı bir yoldur.
Topluluğun benimsediği pratik şudur:
my-go-project/
├── cmd/ // çalıştırılabilir uygulamalar
│ └── myapp-server/
│ └── main.go
├── internal/ // DIŞARIDAN import EDİLEMEZ (kapsülleme!)
│ ├── domain/ // çekirdek iş varlıkları
│ ├── storage/postgres/ // veri kalıcılığı
│ └── handler/http/ // API handler'ları
├── pkg/ // YALNIZCA gerçekten yeniden kullanılabilir kütüphane ise
├── go.mod
└── go.sum
İki anahtar dizin: cmd/ her çalıştırılabilir uygulamanın main.go’sunu barındırır; internal/ ise Go derleyicisinin zorunlu kıldığı bir gizlilik kalkanıdır — buradaki kod modül dışından import edilemez. Paketleri db/ gibi ne olduklarına göre değil, auth/ veya storage/postgres gibi ne yaptıklarına göre isimlendirin. Bu yapının, Clean Architecture yazımdaki katman ayrımıyla nasıl örtüştüğüne dikkat edin.
Sonuç
Yüzlerce Go reposunu incelemek bana tek bir gerçeği tekrar tekrar gösterdi: acemi de olsanız kıdemli de, aynı tuzaklara düşüyoruz. Sessiz katil hatalı error yönetiminden bellek yiyen goroutine sızıntılarına, “tanrı struct”lardan dağınık proje yapısına kadar bu hatalar her yerde — ama hepsi tamamen çözülebilir.
İyi haber şu: Go, en yeni sürümlerinde bile sağlam, bakımı kolay ve hızlı uygulamalar yazmak için gereken tüm araçları ve deyimleri (idiom) veriyor. Mesele bu araçları bilmek değil, neden var olduklarını anlayıp tutarlı biçimde uygulamak. İyi bir Go geliştiricisi olmak, dili ezbere bilmekten çok, bu tuzakları bir mil öteden görebilmektir.
Peki siz bu hatalardan en çok hangisiyle karşılaştınız? Kendi kod tabanınızda bir denetim yaparken bu beş başlığı bir kontrol listesi gibi kullanmanızı öneririm.