Belajar Golang dari Nol #16: Struktur Project Go yang Rapi
Di Belajar Golang dari Nol #15 kita sudah menaruh API produk di server. Binary sudah jalan sebagai service. Domain sudah dipasang. Secara fungsi, aplikasinya hidup dan bisa dipakai orang lain.
Tapi coba buka lagi folder projectnya. Kemungkinan besar isinya masih sedikit file yang gemuk. Satu main.go yang menampung hampir semua hal. Selama masih dikerjakan sendiri, itu belum terasa mengganggu. Masalah baru muncul saat aplikasinya tumbuh atau ada orang kedua yang ikut menyentuh kodenya.
Bagian ini membahas cara merapikan project Go supaya tetap enak dipakai jangka panjang. Kita tidak menambah fitur baru. Kita memindahkan kode yang sudah ada ke tempat yang lebih masuk akal.
Tanda project Go mulai berantakan
Sebelum merapikan, kenali dulu gejalanya. Kalau salah satu tanda di bawah ini terasa familiar, project Anda sudah waktunya ditata.
- File
main.gotembus ratusan baris. Setiap kali ingin mengubah satu hal kecil, Anda harus scroll jauh untuk menemukannya. - Handler menempel langsung ke query database. Fungsi yang tugasnya membalas HTTP juga menulis
SELECTdanINSERTdi dalamnya. - Susah dites. Untuk menguji satu aturan sederhana seperti “harga tidak boleh nol”, Anda terpaksa menyalakan database dulu.
- Dua orang mengedit file yang sama. Setiap pull request berakhir dengan konflik di file yang itu-itu saja.
- Aturan bisnis tercecer. Validasi nama produk ada di handler, validasi stok ada di tempat lain, dan tidak ada yang tahu mana yang benar.
Inti masalahnya satu. Terlalu banyak tanggung jawab dijejalkan ke satu tempat.
Prinsip: pisahkan berdasarkan tanggung jawab
Banyak pemula memisahkan file berdasarkan jenisnya. Semua struct masuk folder models. Semua fungsi HTTP masuk folder handlers. Semua query masuk folder database. Kelihatan rapi di awal, tapi setiap kali menambah satu fitur Anda harus membuka tiga folder berbeda.
Cara yang lebih tahan lama adalah memisahkan berdasarkan tanggung jawab, lalu mengelompokkannya per fitur. Untuk aplikasi web biasa, tiga lapis sudah cukup.
Lapis 1: handler
Tugasnya menerima request dan membalas response. Dia membaca body JSON, mengambil parameter dari URL, memanggil lapis di bawahnya, lalu menulis hasilnya kembali sebagai JSON. Handler tidak boleh tahu soal SQL. Handler juga tidak memutuskan apakah sebuah harga valid.
Lapis 2: service
Tempat aturan bisnis tinggal. Nama produk wajib diisi. Harga harus lebih dari nol. Stok minus dianggap nol. Service tidak tahu apa-apa soal HTTP. Dia tidak tahu status code, tidak tahu header, tidak tahu request. Karena itu, service bisa dipakai ulang dari mana saja, termasuk dari CLI atau worker.
Lapis 3: repository
Satu-satunya bagian yang bicara ke database. Query SQL hanya boleh ada di sini. Kalau suatu hari Anda ganti dari PostgreSQL ke MySQL, atau menambah cache, yang berubah cuma lapis ini.
Arah panggilannya selalu satu arah: handler memanggil service, service memanggil repository. Tidak pernah sebaliknya. Aturan sederhana ini yang menjaga project tetap waras.
Struktur folder yang disarankan
Untuk API produk yang kita bangun sejak bagian 11, struktur folder yang saya sarankan seperti ini.
tokoapi/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── middleware/
│ │ ├── log.go
│ │ └── recover.go
│ └── produk/
│ ├── handler.go
│ ├── model.go
│ ├── repository.go
│ ├── service.go
│ └── service_test.go
├── go.mod
└── go.sum
Kenapa ada folder cmd
Folder cmd berisi titik masuk program. Satu subfolder untuk satu binary. Sekarang isinya baru cmd/api. Nanti kalau Anda butuh program tambahan, misalnya pengirim laporan harian, tinggal buat cmd/worker/main.go tanpa mengganggu API. Keduanya berbagi kode yang sama di internal.
Perintah build dan run jadi sedikit lebih panjang, tapi masih sederhana.
go run ./cmd/api
go build -o bin/api ./cmd/api
Kenapa ada folder internal
Ini bukan sekadar kebiasaan penamaan. internal adalah fitur bawaan Go. Package yang ada di dalam folder bernama internal hanya bisa diimport oleh kode yang berada di dalam module yang sama. Project lain yang mengimport module Anda akan langsung ditolak oleh compiler.
Manfaatnya jelas. Anda bebas mengubah isi internal/produk kapan saja tanpa takut merusak project orang. Kalau suatu saat Anda memang ingin membagikan sebagian kode ke publik, pindahkan package itu keluar dari internal, misalnya ke folder pkg. Selama belum yakin, taruh saja semuanya di internal.
Refactor bertahap dari kode bagian 12 dan 13
Jangan bongkar semuanya sekaligus. Pindahkan satu lapis, jalankan aplikasinya, pastikan masih normal, baru lanjut ke lapis berikutnya.
Urutan yang paling aman adalah dari bawah ke atas. Mulai dari model, lalu repository, lalu service, terakhir handler. Alasannya sederhana. Lapis bawah tidak bergantung pada lapis atas, jadi memindahkannya tidak akan membuat kode lain rusak. Kalau Anda mulai dari handler, semua yang dipanggilnya masih berserakan dan Anda akan terjebak memindahkan banyak hal sekaligus.
Satu file berisi konfigurasi juga sebaiknya ikut dipindah ke internal/config. Isinya membaca environment variable yang sudah kita siapkan waktu deploy di bagian 15, lalu mengembalikannya sebagai struct. Dengan begitu, tidak ada lagi os.Getenv yang tersebar di tengah kode.
model.go
Mulai dari yang paling gampang. Pindahkan struct produk ke filenya sendiri.
package produk
import "time"
type Produk struct {
ID int64 `json:"id"`
Nama string `json:"nama"`
Harga int64 `json:"harga"`
Stok int `json:"stok"`
DibuatPada time.Time `json:"dibuat_pada"`
}
Perhatikan nama packagenya: produk, bukan models. Karena package sudah bernama produk, penulisan dari luar jadi produk.Produk. Tidak perlu menambah awalan atau akhiran apa pun pada nama struct.
repository.go
Sekarang pindahkan semua query yang tadinya menempel di handler. Repository menyimpan *sql.DB sebagai field, bukan mengambilnya dari variabel global.
package produk
import (
"context"
"database/sql"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) Ambil(ctx context.Context, id int64) (Produk, error) {
query := `SELECT id, nama, harga, stok, dibuat_pada FROM produk WHERE id = $1`
var p Produk
err := r.db.QueryRowContext(ctx, query, id).
Scan(&p.ID, &p.Nama, &p.Harga, &p.Stok, &p.DibuatPada)
if err != nil {
return Produk{}, err
}
return p, nil
}
func (r *Repository) Simpan(ctx context.Context, p Produk) (Produk, error) {
query := `INSERT INTO produk (nama, harga, stok)
VALUES ($1, $2, $3)
RETURNING id, dibuat_pada`
err := r.db.QueryRowContext(ctx, query, p.Nama, p.Harga, p.Stok).
Scan(&p.ID, &p.DibuatPada)
if err != nil {
return Produk{}, err
}
return p, nil
}
service.go
Semua if validasi yang tadinya berserakan di handler kita kumpulkan di sini. Errornya dibuat sebagai variabel supaya bisa dicek dari luar.
package produk
import (
"context"
"errors"
"strings"
)
var (
ErrNamaKosong = errors.New("nama produk tidak boleh kosong")
ErrHargaSalah = errors.New("harga produk harus lebih dari nol")
)
type PenyimpanProduk interface {
Ambil(ctx context.Context, id int64) (Produk, error)
Simpan(ctx context.Context, p Produk) (Produk, error)
}
type Service struct {
repo PenyimpanProduk
}
func NewService(repo PenyimpanProduk) *Service {
return &Service{repo: repo}
}
func (s *Service) Buat(ctx context.Context, p Produk) (Produk, error) {
p.Nama = strings.TrimSpace(p.Nama)
if p.Nama == "" {
return Produk{}, ErrNamaKosong
}
if p.Harga <= 0 {
return Produk{}, ErrHargaSalah
}
if p.Stok < 0 {
p.Stok = 0
}
return s.repo.Simpan(ctx, p)
}
func (s *Service) Detail(ctx context.Context, id int64) (Produk, error) {
return s.repo.Ambil(ctx, id)
}
Ada satu hal penting di sini. Field repo bertipe PenyimpanProduk, bukan *Repository. Alasannya kita bahas sebentar lagi di bagian testing.
handler.go
Sisa tugas handler tinggal tiga: baca request, panggil service, tulis JSON.
package produk
import (
"encoding/json"
"net/http"
"strconv"
)
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler {
return &Handler{svc: svc}
}
func (h *Handler) Buat(w http.ResponseWriter, r *http.Request) {
var body Produk
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
tulisJSON(w, http.StatusBadRequest, map[string]string{"error": "body tidak valid"})
return
}
hasil, err := h.svc.Buat(r.Context(), body)
if err != nil {
tulisJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
tulisJSON(w, http.StatusCreated, hasil)
}
func (h *Handler) Detail(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
tulisJSON(w, http.StatusBadRequest, map[string]string{"error": "id tidak valid"})
return
}
hasil, err := h.svc.Detail(r.Context(), id)
if err != nil {
tulisJSON(w, http.StatusNotFound, map[string]string{"error": "produk tidak ditemukan"})
return
}
tulisJSON(w, http.StatusOK, hasil)
}
func tulisJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
Handler ini jadi jauh lebih pendek daripada versi bagian 12. Tidak ada SQL, tidak ada validasi. Kalau ada bug di aturan harga, Anda tahu persis harus membuka file mana.
Merangkai dependensi tanpa framework
Di bahasa lain, urusan seperti ini sering diserahkan ke library dependency injection. Di Go, caranya jauh lebih sederhana dan tidak butuh library sama sekali.
Polanya cuma dua langkah. Pertama, simpan dependensi sebagai field di dalam struct. Kedua, buat fungsi constructor yang menerimanya dari luar. Itu yang sudah kita lakukan lewat NewRepository, NewService, dan NewHandler.
Semua rangkaiannya dipasang di satu tempat: main.go.
package main
import (
"log"
"net/http"
"github.com/vandy/tokoapi/internal/config"
"github.com/vandy/tokoapi/internal/middleware"
"github.com/vandy/tokoapi/internal/produk"
)
func main() {
cfg := config.Muat()
db, err := config.BukaDB(cfg.DatabaseURL)
if err != nil {
log.Fatalf("gagal konek database: %v", err)
}
defer db.Close()
repo := produk.NewRepository(db)
svc := produk.NewService(repo)
h := produk.NewHandler(svc)
mux := http.NewServeMux()
mux.HandleFunc("POST /produk", h.Buat)
mux.HandleFunc("GET /produk/{id}", h.Detail)
handler := middleware.Log(middleware.Recover(mux))
log.Printf("server jalan di %s", cfg.Alamat)
if err := http.ListenAndServe(cfg.Alamat, handler); err != nil {
log.Fatal(err)
}
}
Sekarang main.go bisa dibaca dalam satu tarikan napas. Baca config, buka database, rangkai tiga lapis, daftarkan route, pasang middleware dari bagian 13, nyalakan server. Orang baru yang bergabung ke tim bisa memahami alur aplikasi hanya dari file ini.
Kenapa struktur ini bikin testing gampang
Di bagian 14 kita menulis test dan sempat repot karena kodenya menempel ke database. Struktur baru ini menyelesaikan masalah itu.
Kuncinya ada di interface PenyimpanProduk tadi. Karena Service menyimpan interface, bukan struct konkret, kita bisa memberinya repository palsu saat testing. Ini pemakaian nyata dari interface yang kita pelajari di bagian 9.
package produk
import (
"context"
"errors"
"testing"
)
type repoPalsu struct {
tersimpan Produk
}
func (r *repoPalsu) Ambil(ctx context.Context, id int64) (Produk, error) {
return Produk{ID: id, Nama: "Kopi Susu", Harga: 18000}, nil
}
func (r *repoPalsu) Simpan(ctx context.Context, p Produk) (Produk, error) {
p.ID = 1
r.tersimpan = p
return p, nil
}
func TestBuatMenolakNamaKosong(t *testing.T) {
svc := NewService(&repoPalsu{})
_, err := svc.Buat(context.Background(), Produk{Nama: " ", Harga: 18000})
if !errors.Is(err, ErrNamaKosong) {
t.Fatalf("harusnya ErrNamaKosong, dapat: %v", err)
}
}
Test ini jalan dalam hitungan milidetik. Tidak ada database yang perlu dinyalakan, tidak ada tabel yang perlu dibersihkan. Anda bisa menjalankannya di laptop maupun di CI tanpa setup tambahan.
Perhatikan juga bahwa repoPalsu tidak perlu mengimplementasikan seluruh method Repository. Cukup method yang ada di interface. Semakin kecil interfacenya, semakin mudah dipalsukan.
Kapan struktur ini tidak perlu
Bagian ini sering dilewatkan orang, padahal penting.
Kalau program Anda cuma 200 baris, satu file main.go itu sah. Script yang mengubah CSV jadi JSON tidak butuh folder internal. Tool kecil yang memanggil satu API tidak butuh lapis service. Membuat lima lapis untuk kode sependek itu justru bikin Anda lebih lama menemukan sesuatu.
Pegangan yang saya pakai kira-kira begini:
- Di bawah 300 baris, satu file. Belum ada yang perlu dipisah.
- Mulai ada dua atau tiga fitur. Pecah per fitur dulu, belum perlu tiga lapis penuh.
- Sudah ada database, aturan bisnis, dan lebih dari satu orang yang mengerjakan. Baru pakai struktur tiga lapis seperti di atas.
Struktur mengikuti kebutuhan, bukan gengsi. Kalau Anda tidak bisa menjelaskan kenapa sebuah folder ada, kemungkinan folder itu memang belum perlu ada.
Konvensi penamaan file dan folder
Go punya beberapa kebiasaan yang sebaiknya diikuti supaya kode Anda terasa familiar bagi programmer Go lain.
- Nama folder dan package huruf kecil semua. Tanpa underscore, tanpa strip, tanpa huruf besar. Tulis
produk, bukanProdukatauproduk_service. - Nama package sebaiknya kata tunggal.
config,middleware,produk. Hindari nama kosong makna sepertiutils,helpers, ataucommon, karena isinya cepat berubah jadi tempat sampah. - Nama file boleh pakai underscore. Contohnya
produk_handler.go. Tapi karena packagenya sudah bernamaproduk, cukuphandler.go. - Akhiran
_test.gowajib untuk file test. Ini aturan compiler, bukan selera. - Jangan mengulang nama package di nama tipe. Cukup
produk.Service, janganproduk.ProdukService.
Satu catatan jujur soal struktur folder. Tim Go tidak pernah merilis struktur project resmi. Repository populer bernama golang-standards/project-layout sering dikutip sebagai standar, padahal itu proyek komunitas dan banyak developer Go senior menganggapnya kelewat rumit untuk aplikasi biasa.
Jadi jangan bingung kalau Anda menemukan lima tutorial dengan lima struktur berbeda. Yang penting bukan meniru persis, melainkan konsisten di dalam satu project dan punya alasan untuk setiap folder yang Anda buat.
Rangkuman
Merapikan project bukan soal estetika. Tujuannya supaya perubahan berikutnya lebih murah. Tiga hal yang perlu Anda bawa dari bagian ini: pisahkan berdasarkan tanggung jawab, rangkai dependensi lewat constructor di main.go, dan pakai interface supaya service bisa dites tanpa database.
Coba refactor project Anda sendiri sekarang. Pindahkan satu lapis dulu, jalankan, baru lanjut.
Di bagian 17 kita masuk ke topik yang selalu muncul begitu aplikasi dipakai banyak orang: Context di Go: Timeout, Pembatalan, dan Request yang Sehat. Parameter ctx yang sejak tadi kita bawa ke mana-mana akhirnya akan terpakai sungguhan di sana.
Kalau Anda butuh bantuan menata ulang aplikasi Go yang sudah terlanjur besar atau ingin membangun sistem baru dengan fondasi yang benar sejak awal, tim kami siap membantu lewat layanan pengembangan sistem dan aplikasi.
Artikel Lainnya di Kategori Golang
Golang 6 Agustus 2026
Belajar Golang dari Nol #11: REST API Pertamamu dengan net/http
Seri Go pemula sampai di gerbang backend: bikin REST API dengan net/http, routing method Go 1.22, JSON response, dan API produk mini lengkap.
Baca Artikel
Golang 31 Juli 2026
Belajar Golang dari Nol #5: Slice dan Map, Kumpulan Data di Go
Lanjutan seri Go pemula: slice, append, map, comma ok idiom, jebakan nil map, plus latihan keranjang belanja mini dengan pengecekan stok.
Baca Artikel
Golang 10 Agustus 2026
Belajar Golang dari Nol #15: Deploy API Go ke Server Production
Bawa API Go dari laptop ke server: cross compile binary, environment variable, systemd service, reverse proxy Nginx, sampai HTTPS gratis certbot.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel