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:
- Handler jadi tahu soal database — bocor abstraction layer
- Error message jadi teknis — user lihat "no rows in result set" ❌
- 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.gousecase/scan_secret.gorepository/git_repo.gohandler/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:
- Folder structure bukan CA — dependency rule adalah intinya, sisanya detail
- Interface kecil di package pemakai — biarkan consumer yang define kebutuhannya
- Manual wiring itu fine — DI framework baru diperlukan di skala 50+ service
- Error adalah boundary — domain error pure, mapping-nya di external layer
- 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.