Episode ini menyusun proyek Gin dengan clean architecture: pembagian handler, service, dan repository, penggunaan folder internal, package per domain, dependency injection sederhana, serta pemisahan router setup agar mudah diuji dan di-scale.

Selama episode 3 sampai 7, semua kode ditulis dalam satu file main.go. Itu wajar untuk belajar, tetapi tidak bertahan saat project tumbuh. Episode 8 ini mengajarkan cara menyusun proyek Gin dengan clean architecture — membagi tanggung jawab ke lapisan yang jelas sehingga mudah diuji, mudah diganti, dan mudah dikerjakan banyak orang.
Prinsip yang dianut sederhana: handler tidak boleh tahu detail database, service tidak boleh tahu detail HTTP, dan repository tidak boleh tahu cara request datang. Masing-masing lapisan hanya bergantung pada kontrak (interface), bukan implementasi.
Semua logic dalam satu file menimbulkan beberapa masalah: sulit diuji karena handler terikat langsung ke koneksi database, sulit diubah karena perubahan schema merembet ke semua handler, dan mudah konflik saat bekerja dalam tim. Karena itu kita memisahkan peran:
Dengan pemisahan, setiap lapisan bisa diuji terpisah. Handler diuji dengan httptest, service diuji dengan repository palsu (mock), dan repository diuji langsung ke database. Ini akan kalian praktikkan di episode 17.
Contoh layout standar untuk proyek Gin:
belajar-gin/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── router/
│ │ └── router.go
│ ├── user/
│ │ ├── handler.go
│ │ ├── service.go
│ │ └── repository.go
│ └── middleware/
│ └── auth.go
└── go.modFolder internal/ adalah fitur khusus Go: package di dalamnya tidak bisa diimpor dari luar modul. Ini membatasi pengguna akses dan menjaga API internal tetap privat. Folder cmd/server berisi entry point, sedangkan satu folder per domain (misalnya user, order, payment) menampung handler, service, dan repository domain tersebut.
cmd/server/main.go hanya merangkai semua dependensi:
func main() {
cfg := config.Load()
db := config.ConnectDB(cfg)
repo := user.NewRepository(db)
svc := user.NewService(repo)
handler := user.NewHandler(svc)
r := router.New(handler)
r.Run(":8080")
}Di dalam internal/user, masing-masing file punya tanggung jawab sendiri. Repository mendefinisikan kontrak akses data:
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, u *User) error
}
type userRepo struct {
db *sql.DB
}
func NewRepository(db *sql.DB) UserRepository {
return &userRepo{db: db}
}Fungsi NewRepository(db *sql.DB) UserRepository mengembalikan interface sambil menyembunyikan struct konkretnya. Ini memudahkan penggantian implementasi dan pembuatan mock di tes.
Service memakai repository, handler memakai service:
type UserService interface {
GetUser(ctx context.Context, id int64) (*User, error)
}
type userService struct {
repo UserRepository
}
func (s *userService) GetUser(ctx context.Context, id int64) (*User, error) {
return s.repo.FindByID(ctx, id)
}type UserHandler struct {
svc UserService
}
func NewHandler(svc UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) GetUser(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(400, gin.H{"error": "id tidak valid"})
return
}
user, err := h.svc.GetUser(c.Request.Context(), id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, user)
}Handler GetUser tidak tahu cara data disimpan — ia hanya memanggil h.svc.GetUser(c.Request.Context(), id). Detail HTTP dan detail database terisolasi di masing-masing lapisan.
Ketergantungan disuntikkan melalui konstruktor, bukan diimpor langsung di dalam handler. Keuntungannya: handler bisa menerima implementasi berbeda, termasuk mock saat pengujian. Ini disebut constructor injection, pola paling sederhana untuk Go. Untuk project yang lebih besar, kalian bisa memakai container seperti wire, fx, atau dig, tetapi untuk sebagian besar kasus constructor injection manual sudah cukup dan paling mudah dilacak.
Router juga dipisah ke file sendiri agar bisa dibuat ulang dengan cepat saat tes:
func New(userHandler *user.UserHandler) *gin.Engine {
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
api := r.Group("/api")
api.GET("/users/:id", userHandler.GetUser)
return r
}Fungsi router.New(userHandler) mengembalikan engine yang siap dipakai. Dengan pola ini, tes di episode 17 cukup memanggil router.New(mockHandler) dan engine baru lahir setiap tes tanpa efek samping global.
Inti yang harus dibawa pulang:
internal/ menjaga package privat dari impor luar modul.router.New agar engine mudah dibuat ulang saat tes.main.go hanya menjadi wiring, bukan tempat logic.Di episode 9 selanjutnya kita akan membedah integrasi database & ORM — koneksi PostgreSQL dengan pgxpool, connection pooling, migrasi database, dan implementasi repository pattern dengan CRUD lengkap di handler Gin.