Göktay Logo

Yüzlerce Go Reposu İnceledim: En Sık Yapılan 5 Hata

7 dk okuma

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:

Çö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.

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.

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.

Çö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.

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.