← Kembali ke Blog

Clean Architecture di Go: 5 Hal yang Baru Saya Pahami Setelah 5 Tahun

Saya mulai serius dengan Clean Architecture di Go sekitar tahun 2021, pas pertama kali join di KoinWorks. Waktu itu saya baca buku Robert C. Martin, nonton The Clean Architecture talk di YouTube, lalu pulang dengan satu keyakinan bulat:

Clean Architecture adalah satu-satunya cara bikin software yang benar.

Spoiler: pandangan itu terlalu naif.

Bukan berarti Clean Architecture itu buruk — justru sebaliknya, ini pattern yang powerful kalau diterapkan dengan benar. Tapi 5 tahun dan 3 perusahaan kemudian — KoinWorks, PrivyID, Indodax — plus beberapa side project pribadi (HRIS multi-merchant, secret scanner, routing app), saya akhirnya paham mana yang benar-benar penting dan mana yang cuma dogma yang bikin lemot.

Ini 5 hal yang baru benar-benar saya pahami setelah setengah dekade salah dan benarnya.


1. Clean Architecture Bukan Tentang Folder Structure

Waktu pertama belajar CA, yang saya lakukan adalah: riset "struktur folder Go Clean Architecture" di Google, nemu template, lalu copy-paste struktur foldernya. Hasilnya? Project dengan:

handler/
usecase/
service/
repository/
model/
dto/
mapper/
helper/

Tujuh layer, tiga di antaranya cuma jadi pass-through. Dan ironisnya, model/ dipanggil di semua layer lain — termasuk handler/ yang harusnya layer terluar. Dependency rule sudah bocor dari hari pertama dan saya bahkan tidak sadar.

Yang benar: Dependency Rule itu kuncinya

Bob Martin bilang: "Source code dependencies must point inward — toward the domain." Bukan soal berapa folder yang kamu buat, tapi soal siapa yang boleh tahu siapa.

// ❌ SALAH: entity maling import "net/http"
package entity

import "net/http"

type UserResponse struct {
    Status  int    `json:"status"`  // http.Status response code nyangkut di entity
    Message string `json:"message"`
}

// ✅ BENAR: entity pure Go, tidak tahu soal HTTP atau framework
package entity

type User struct {
    ID        string    `json:"id"`
    Email     string    `json:"email"`
    FullName  string    `json:"full_name"`
    CreatedAt time.Time `json:"created_at"`
}

Kalau entity.User punya field http.StatusOK artinya domain layer Anda sudah punya dependensi ke eksternal layer — poof, dependency rule bye-bye.

Pelajaran saya: Sekarang di semua project — termasuk Indodax dan side project — saya cukup pakai 4 folder utama:

handler/     →  entry point (HTTP handler, gRPC handler, subscriber)
usecase/     →  business logic (kecil, fokus per use case)
repository/  →  data access (PostgreSQL, Redis, Kafka)
entity/      →  domain model murni

Bukan karena "ini struktur CA yang benar", tapi karena ini struktur minimum yang menjaga dependency rule tetap tegak. Kalau besok butuh layer tambahan, saya tambah. Tapi mulai dari sini dulu.


2. Interface: Kecil, Implisit, dan Didefinisikan di Pemakai

Ini pelajaran paling keras buat saya — terutama karena saya datang dari latar belakang Java.

Di Java, interface itu kontrak eksplisit yang dideklarasikan di package yang sama dengan implementasinya. UserRepository interface didefinisikan di package repository, lalu UserRepositoryImpl mengimplementasikannya.

Saya bawa kebiasaan ini ke Go selama 1-2 tahun pertama. Hasilnya: file interface raksasa yang dipenuhi method, dan setiap kali ada perubahan, dua file (interface + implementasi) harus diedit. Ditambah lagi: interface ini saya simpan di package repository/, padahal yang make ada di usecase/. Ironis.

Yang benar: Interface kecil, di package pemakai

Di Go, interface itu implisit — sebuah tipe cukup mengimplementasikan method yang dibutuhkan, tanpa harus implements eksplisit. Dan prinsip ini punya konsekuensi: definisikan interface di tempat yang memakainya, bukan di tempat yang membuatnya.

// ❌ SALAH: Interface raksasa di package repository
package repository

type UserRepository interface {
    FindByID(id string) (*entity.User, error)
    FindByEmail(email string) (*entity.User, error)
    FindAll(filter Filter) ([]entity.User, error)
    Create(user *entity.User) error
    Update(user *entity.User) error
    Delete(id string) error           // 12 method — dipaksa implement semua
    ...
}

// ✅ BENAR: Interface kecil, di package yang make
package usecase

// UserFinder — cuma butuh method ini doang
type UserFinder interface {
    FindByID(id string) (*entity.User, error)
}

type GetUserProfileUseCase struct {
    userFinder UserFinder  // depend ke interface, bukan ke concrete type
}

Sekarang UserRepository di package repository bisa punya 20 method — terserah dia. usecase cukup define interface UserFinder yang cuma butuh 1 method. Ini yang dinamain Interface Segregation Principle dalam bentuk paling natural di Go.

Pelajaran dari PrivyID: Di PrivyID, saya pertama kali lihat production Go code yang benar-benar memisahkan interface sesuai kebutuhan consumer-nya. Satu domain entity bisa punya 3-4 interface kecil (UserReader, UserWriter, UserFinder) yang dipakai oleh use case yang berbeda. Hasilnya: testing jadi lebih gampang (mocking cukup 1-2 method), dan kode jadi lebih gampang di-refactor.


3. Dependency Injection: Manual Wiring Lebih dari Cukup

Waktu baru pindah ke Go, saya panik: "Go nggak punya Spring? Terus gimana cara DI-nya?"

Jawabannya sederhana: constructor injection manual.

func main() {
    db := postgres.NewConn()
    userRepo := repository.NewUserRepository(db)
    userUsecase := usecase.NewUserUsecase(userRepo)
    handler := handler.NewUserHandler(userUsecase)

    http.ListenAndServe(":8080", handler)
}

Tapi saya pikir ini too simple. Pasti ada yang lebih baik. Jadi saya coba Google Wire — dan dapat kompleksitas yang nggak sebanding. Generated code yang susah dibaca, error message yang cryptic, dan satu kali circular dependency yang bikin saya mengutuk semesta.

Lalu saya coba uber/fx — lebih enak karena ada lifecycle management, tapi testing tetap jadi masalah. Mau mock dependency di fx? Siap-siap baca 3 halaman dokumentasi.

Yang benar: Manual wiring dulu, DI framework nanti

Sekarang aturan saya simpel:

Jumlah Service Rekomendasi
1-15 service Manual wiring — simpel, jelas, testable
15-50 service Manual wiring masih OK, bisa split jadi beberapa wire function
50+ service / tim besar Baru mulai lirik DI framework

Side project kayak HRIS multi-merchant dan secret scanner saya jalan dengan manual wiring. Malah secret scanner cuma main.go satu file — nggak butuh DI framework sama sekali.

// ✅ Main function HRIS - kurang dari 20 service, manual wiring aja
func main() {
    db := postgres.NewConn(cfg.Database)
    cache := cache.NewInMemory()

    userRepo := repository.NewUserRepository(db)
    attendanceRepo := repository.NewAttendanceRepository(db, cache)
    companyRepo := repository.NewCompanyRepository(db)

    authUsecase := usecase.NewAuthUsecase(userRepo, cfg.JWT)
    attendanceUsecase := usecase.NewAttendanceUsecase(attendanceRepo, companyRepo)
    companyUsecase := usecase.NewCompanyUsecase(companyRepo)

    authHandler := handler.NewAuthHandler(authUsecase)
    attendanceHandler := handler.NewAttendanceHandler(attendanceUsecase)
    companyHandler := handler.NewCompanyHandler(companyUsecase)

    r := chi.NewRouter()
    // ... register routes
    http.ListenAndServe(":"+cfg.Port, r)
}

Baris per baris, jelas dependency-nya. Kalau ada error, langsung kelihatan. Junior dev bisa baca dan paham dalam 2 menit.


4. Error Handling: Domain Error Harus Pure, Mapping-nya di Boundary

Ini pelajaran yang baru benar-benar saya pahami di Indodax.

Di exchange crypto, error handling itu bukan sekadar "return 404" atau "return 500". Setiap error punya implikasi finansial. InsufficientFund harus di-handle beda dengan OrderNotFound. Klien perlu tahu persis apa yang salah — plus dalam bahasa Indonesia atau Inggris tergantung preferensi mereka.

Masalahnya: kalau error dari repository (SQL sql.ErrNoRows) bocor sampai ke handler, maka:

  1. Handler jadi tahu soal database — bocor abstraction layer
  2. Error message jadi teknis — user lihat "no rows in result set"
  3. Testing HTTP handler jadi perlu mock database — overhead nggak perlu

Yang benar: Domain error + mapping di boundary

package entity

// DomainError — error murni Go, tanpa import external.
type DomainError struct {
    Code    string
    Message string
    Err     error
}

func (e *DomainError) Error() string {
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

func (e *DomainError) Unwrap() error {
    return e.Err
}

var (
    ErrUserNotFound     = &DomainError{Code: "USER_NOT_FOUND", Message: "user tidak ditemukan"}
    ErrInsufficientFund = &DomainError{Code: "INSUFFICIENT_FUND", Message: "saldo tidak mencukupi"}
    ErrOrderExpired     = &DomainError{Code: "ORDER_EXPIRED", Message: "pesanan sudah kadaluarsa"}
)
package handler

func mapDomainErrorToHTTP(err error) int {
    var domainErr *entity.DomainError
    if !errors.As(err, &domainErr) {
        log.Printf("unexpected error: %v", err)
        return http.StatusInternalServerError
    }
    switch domainErr.Code {
    case "USER_NOT_FOUND":
        return http.StatusNotFound
    case "INSUFFICIENT_FUND":
        return http.StatusUnprocessableEntity
    case "ORDER_EXPIRED":
        return http.StatusGone
    default:
        return http.StatusBadRequest
    }
}
// Di usecase — return domain error, nggak peduli HTTP
func (uc *TransferUsecase) Transfer(ctx context.Context, req TransferRequest) (*entity.TransferResult, error) {
    sender, err := uc.userFinder.FindByID(ctx, req.SenderID)
    if err != nil {
        return nil, fmt.Errorf("find sender: %w", err)
    }
    receiver, err := uc.userFinder.FindByID(ctx, req.ReceiverID)
    if err != nil {
        return nil, fmt.Errorf("find receiver: %w", err)
    }
    if sender.Balance < req.Amount {
        return nil, entity.ErrInsufficientFund
    }
    // ... proceed
}

Dengan pendekatan ini:

  • Domain layer pure — nggak tahu soal HTTP, gRPC, atau apapun
  • Handler satu-satunya tempat yang paham HTTP status code
  • Error message konsisten — bisa dipakai untuk response JSON, log, atau monitoring
  • Testing — usecase cukup test domain error, handler cukup test mapping

5. Pragmatism > Purism: Kapan Saya Melanggar Clean Architecture

Ini mungkin bagian paling penting dari artikel ini.

Setelah 5 tahun, saya sadar: Clean Architecture itu pedoman, bukan hukum. Ada kalanya melanggar aturan itu lebih baik daripada kaku mengikutinya.

Kapan saya melanggar

Skenario Pelanggaran Alasan
CRUD sederhana Skip usecase layer, handler langsung ke repository Usecase cuma pass-through; nambah file tanpa nilai tambah
Side project (secret scanner) Pake struct konkret tanpa interface 1 implementasi, 0% chance ganti database/driver
Prototype / MVP Gabung handler + usecase di 1 file Speed > purity — refactor setelah ada PMF
CLI tool kecil Akses database langsung dari main.go Program selesai dalam 2 file, nggak perlu layer

Contoh nyata: Secret Scanner

// ✅ Side project yang cukup dalam 1 file + struct konkret
func main() {
    repo := NewGitRepo("/tmp/test-repo")   // struct konkret
    results := scanSecrets(repo)           // function biasa
    for _, r := range results {
        printAlert(r)                       // langsung, bukan method abstraction
    }
}

type SecretResult struct {
    File    string
    Line    int
    Type    string
    Context string
}

func scanSecrets(repo *GitRepo) []SecretResult {
    results := []SecretResult{}
    for _, file := range repo.Files {
        if matched, secretType := matchPattern(file); matched {
            results = append(results, SecretResult{
                File:    file.Path,
                Line:    file.MatchLine,
                Type:    secretType,
                Context: file.Snippet,
            })
        }
    }
    return results
}

Kalau saya paksa pake Clean Architecture di sini, saya akan punya:

  • entity/secret_result.go
  • usecase/scan_secret.go
  • repository/git_repo.go
  • handler/cli_handler.go

...menambah 200 baris abstraction untuk kode yang totalnya cuma 150 baris. Itu bukan clean, itu waste.

Prinsip yang saya pakai sekarang

"Tahu kapan harus menerapkan Clean Architecture dan kapan cukup pake function biasa — itu yang membedakan senior engineer dari yang cuma ngikutin dogma."

Atau dalam bahasa Rob Pike: "A little copying is better than a little dependency." Kadang, langsung lebih baik daripada abstraksi yang dipaksakan.


Penutup

Lima tahun dan tiga perusahaan kemudian, ini yang saya pegang:

  1. Folder structure bukan CA — dependency rule adalah intinya, sisanya detail
  2. Interface kecil di package pemakai — biarkan consumer yang define kebutuhannya
  3. Manual wiring itu fine — DI framework baru diperlukan di skala 50+ service
  4. Error adalah boundary — domain error pure, mapping-nya di external layer
  5. Pragmatism beats purism — tahu kapan melanggar aturan lebih penting daripada tahu aturannya

Clean Architecture bukan tentang purity kode atau folder yang rapi. Ini tentang memisahkan apa yang sering berubah dari apa yang jarang berubah. Kalau kamu bisa menjaga boundary itu tetap tipis dan jelas, kamu sudah 80% di sana — bahkan tanpa 7 layer folder.


Punya pengalaman berbeda? Atau setuju dengan poin nomor berapa? Tulis di komentar — diskusi selalu lebih seru daripada doktrin.


Rekomendasi Bacaan

← Kembali ke semua artikel