Kamu mungkin pernah merasakan momen ini: proyek Go yang tadinya sederhana tumbuh menjadi ratusan file, dan satu perubahan di handler HTTP tiba-tiba memengaruhi query database di tempat lain. Tidak ada batas yang jelas antara mana logika bisnis, mana detail teknis. Mengganti ORM saja bisa jadi mimpi buruk karena kode tersebar ke mana-mana.
Clean Architecture hadir sebagai solusi struktural untuk masalah ini. Gagasannya sederhana: pisahkan kode berdasarkan tanggung jawab ke dalam lapisan-lapisan yang tidak saling bergantung sembarangan. Lapisan dalam tidak boleh tahu apa-apa tentang lapisan luar. Database, HTTP framework, library pihak ketiga — semua itu adalah “detail” yang bisa diganti tanpa menyentuh logika bisnis inti.
Artikel ini membahas cara menerapkan Clean Architecture di proyek Go secara praktis, lengkap dengan struktur folder, penulisan interface, dan contoh implementasi untuk aplikasi manajemen proyek.
Mengapa Clean Architecture Cocok untuk Go
Go memiliki beberapa sifat yang justru membuat Clean Architecture terasa natural. Interface di Go bersifat implisit — sebuah tipe otomatis memenuhi interface jika mengimplementasikan semua method-nya. Ini adalah fondasi yang sempurna untuk dependency inversion: lapisan dalam mendefinisikan interface, lapisan luar mengimplementasikannya.
Selain itu, Go mendorong komposisi daripada inheritance. Tidak ada class hierarchy yang rumit. Kode Go yang idiomatis cenderung flat, eksplisit, dan modular — persis seperti yang diinginkan Clean Architecture.
Clean Architecture bukan satu-satunya cara menyusun proyek Go. Hexagonal Architecture (Ports and Adapters) juga populer dan memiliki prinsip yang sangat mirip. Artikel tentang Prabogo, Go framework dengan hexagonal architecture, bisa menjadi referensi tambahan jika kamu tertarik membandingkan keduanya.
Empat Lapisan dalam Clean Architecture
Bayangkan arsitektur ini sebagai lingkaran konsentris. Lapisan paling dalam adalah inti aplikasi, dan setiap lapisan luar boleh bergantung ke dalam, tapi tidak sebaliknya.
Keempat lapisan tersebut adalah:
- Domain — entitas bisnis murni, interface repository, dan business rule yang tidak bergantung pada apapun
- Use Case — logika aplikasi yang mengorkestrasikan entitas domain
- Interface / Delivery — adaptor antara dunia luar (HTTP, gRPC, CLI) dan use case
- Infrastructure — implementasi konkret: database, cache, HTTP client, file system
Struktur folder yang mencerminkan lapisan ini terlihat seperti berikut:
myproject/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── domain/
│ │ ├── project.go
│ │ ├── task.go
│ │ └── repository.go
│ ├── usecase/
│ │ ├── project_usecase.go
│ │ └── task_usecase.go
│ ├── delivery/
│ │ └── http/
│ │ ├── handler.go
│ │ └── router.go
│ └── infra/
│ └── postgres/
│ ├── project_repo.go
│ └── task_repo.go
└── go.mod
Direktori internal/ digunakan agar package-package ini tidak bisa diimpor dari luar module — ini adalah fitur bawaan Go yang sangat berguna untuk menjaga enkapsulasi.
Mendefinisikan Domain Layer
Domain layer adalah lapisan paling penting dan paling stabil. Di sinilah entitas bisnis dan kontrak repository didefinisikan. Lapisan ini tidak mengimpor package apapun selain library standar Go.
Buat file internal/domain/project.go untuk mendefinisikan entitas:
// internal/domain/project.go
package domain
import (
"time"
"errors"
)
// Project merepresentasikan proyek yang dikelola dalam sistem.
type Project struct {
ID string
Name string
Description string
OwnerID string
Status ProjectStatus
CreatedAt time.Time
UpdatedAt time.Time
}
// ProjectStatus mendefinisikan status siklus hidup sebuah proyek.
type ProjectStatus string
const (
StatusActive ProjectStatus = "active"
StatusArchived ProjectStatus = "archived"
StatusCompleted ProjectStatus = "completed"
)
// Validate memastikan data Project sudah benar sebelum disimpan.
func (p *Project) Validate() error {
if p.Name == "" {
return errors.New("project name cannot be empty")
}
if p.OwnerID == "" {
return errors.New("project must have an owner")
}
return nil
}
Perhatikan bahwa Project hanya berisi data bisnis dan business rule. Tidak ada tag json, tidak ada tag db, tidak ada referensi ke framework apapun.
Selanjutnya, buat interface repository di internal/domain/repository.go:
// internal/domain/repository.go
package domain
import "context"
// ProjectRepository mendefinisikan kontrak untuk menyimpan dan mengambil data Project.
// Implementasi konkret ada di lapisan infrastruktur.
type ProjectRepository interface {
Save(ctx context.Context, project *Project) error
FindByID(ctx context.Context, id string) (*Project, error)
FindByOwner(ctx context.Context, ownerID string) ([]*Project, error)
Update(ctx context.Context, project *Project) error
Delete(ctx context.Context, id string) error
}
Interface ini adalah “kontrak” — domain layer menetapkan apa yang dibutuhkannya, tanpa peduli bagaimana cara implementasinya. Ini adalah inti dari dependency inversion.
Menulis Use Case Layer
Use case layer berisi logika aplikasi. Ia menggunakan interface dari domain layer dan tidak peduli apakah data disimpan di PostgreSQL, MySQL, atau bahkan in-memory. Ini yang membuat use case mudah diuji — cukup mock interface-nya.
Buat internal/usecase/project_usecase.go:
// internal/usecase/project_usecase.go
package usecase
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"myproject/internal/domain"
)
// ProjectUseCase menangani semua operasi bisnis terkait Project.
type ProjectUseCase struct {
projectRepo domain.ProjectRepository
}
// NewProjectUseCase membuat instance ProjectUseCase dengan dependency yang diinjeksikan.
func NewProjectUseCase(repo domain.ProjectRepository) *ProjectUseCase {
return &ProjectUseCase{projectRepo: repo}
}
// CreateProject memvalidasi dan menyimpan proyek baru.
func (uc *ProjectUseCase) CreateProject(ctx context.Context, name, description, ownerID string) (*domain.Project, error) {
project := &domain.Project{
ID: uuid.NewString(),
Name: name,
Description: description,
OwnerID: ownerID,
Status: domain.StatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := project.Validate(); err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
if err := uc.projectRepo.Save(ctx, project); err != nil {
return nil, fmt.Errorf("failed to save project: %w", err)
}
return project, nil
}
// GetProjectsByOwner mengambil semua proyek milik seorang pengguna.
func (uc *ProjectUseCase) GetProjectsByOwner(ctx context.Context, ownerID string) ([]*domain.Project, error) {
projects, err := uc.projectRepo.FindByOwner(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("failed to fetch projects: %w", err)
}
return projects, nil
}
// ArchiveProject memindahkan proyek ke status archived.
func (uc *ProjectUseCase) ArchiveProject(ctx context.Context, projectID, requestorID string) error {
project, err := uc.projectRepo.FindByID(ctx, projectID)
if err != nil {
return fmt.Errorf("project not found: %w", err)
}
if project.OwnerID != requestorID {
return fmt.Errorf("only project owner can archive the project")
}
project.Status = domain.StatusArchived
project.UpdatedAt = time.Now()
return uc.projectRepo.Update(ctx, project)
}
Perhatikan bagaimana ProjectUseCase hanya bergantung pada domain.ProjectRepository — sebuah interface, bukan implementasi konkret. Ini yang memungkinkan kita menguji use case ini tanpa koneksi database sama sekali.
Implementasi Infrastructure Layer
Infrastructure layer adalah tempat semua “detail” teknis hidup. Di sini kita mengimplementasikan domain.ProjectRepository menggunakan PostgreSQL.
Buat internal/infra/postgres/project_repo.go:
// internal/infra/postgres/project_repo.go
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"myproject/internal/domain"
)
// ProjectPostgresRepo adalah implementasi domain.ProjectRepository menggunakan PostgreSQL.
type ProjectPostgresRepo struct {
db *sql.DB
}
// NewProjectPostgresRepo membuat instance repository dengan koneksi database.
func NewProjectPostgresRepo(db *sql.DB) *ProjectPostgresRepo {
return &ProjectPostgresRepo{db: db}
}
// Save menyimpan proyek baru ke database.
func (r *ProjectPostgresRepo) Save(ctx context.Context, project *domain.Project) error {
query := `
INSERT INTO projects (id, name, description, owner_id, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`
_, err := r.db.ExecContext(ctx, query,
project.ID,
project.Name,
project.Description,
project.OwnerID,
string(project.Status),
project.CreatedAt,
project.UpdatedAt,
)
if err != nil {
return fmt.Errorf("postgres: save project: %w", err)
}
return nil
}
// FindByID mengambil satu proyek berdasarkan ID-nya.
func (r *ProjectPostgresRepo) FindByID(ctx context.Context, id string) (*domain.Project, error) {
query := `
SELECT id, name, description, owner_id, status, created_at, updated_at
FROM projects WHERE id = $1
`
row := r.db.QueryRowContext(ctx, query, id)
var p domain.Project
var status string
var createdAt, updatedAt time.Time
err := row.Scan(&p.ID, &p.Name, &p.Description, &p.OwnerID, &status, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("project with id %s not found", id)
}
if err != nil {
return nil, fmt.Errorf("postgres: find project by id: %w", err)
}
p.Status = domain.ProjectStatus(status)
p.CreatedAt = createdAt
p.UpdatedAt = updatedAt
return &p, nil
}
// FindByOwner mengambil semua proyek yang dimiliki oleh ownerID tertentu.
func (r *ProjectPostgresRepo) FindByOwner(ctx context.Context, ownerID string) ([]*domain.Project, error) {
query := `
SELECT id, name, description, owner_id, status, created_at, updated_at
FROM projects WHERE owner_id = $1 ORDER BY created_at DESC
`
rows, err := r.db.QueryContext(ctx, query, ownerID)
if err != nil {
return nil, fmt.Errorf("postgres: find projects by owner: %w", err)
}
defer rows.Close()
var projects []*domain.Project
for rows.Next() {
var p domain.Project
var status string
err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.OwnerID, &status, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("postgres: scan project row: %w", err)
}
p.Status = domain.ProjectStatus(status)
projects = append(projects, &p)
}
return projects, rows.Err()
}
// Update menyimpan perubahan pada proyek yang sudah ada.
func (r *ProjectPostgresRepo) Update(ctx context.Context, project *domain.Project) error {
query := `
UPDATE projects
SET name = $1, description = $2, status = $3, updated_at = $4
WHERE id = $5
`
_, err := r.db.ExecContext(ctx, query,
project.Name,
project.Description,
string(project.Status),
project.UpdatedAt,
project.ID,
)
if err != nil {
return fmt.Errorf("postgres: update project: %w", err)
}
return nil
}
// Delete menghapus proyek berdasarkan ID.
func (r *ProjectPostgresRepo) Delete(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, "DELETE FROM projects WHERE id = $1", id)
if err != nil {
return fmt.Errorf("postgres: delete project: %w", err)
}
return nil
}
Implementasi ini tahu detail teknis PostgreSQL — query SQL, tag kolom, error handling spesifik database — tapi detail-detail itu tidak pernah bocor ke lapisan domain atau use case.
Delivery Layer dengan HTTP Handler
Delivery layer adalah jembatan antara dunia luar dan use case. Handler HTTP menerima request, mengekstrak data yang dibutuhkan, memanggil use case, lalu mengembalikan response.
Buat internal/delivery/http/handler.go:
// internal/delivery/http/handler.go
package http
import (
"encoding/json"
"net/http"
"myproject/internal/usecase"
)
// ProjectHandler menangani HTTP request yang berhubungan dengan Project.
type ProjectHandler struct {
projectUseCase *usecase.ProjectUseCase
}
// NewProjectHandler membuat handler dengan use case yang sudah diinjeksikan.
func NewProjectHandler(uc *usecase.ProjectUseCase) *ProjectHandler {
return &ProjectHandler{projectUseCase: uc}
}
type createProjectRequest struct {
Name string `json:"name"`
Description string `json:"description"`
OwnerID string `json:"owner_id"`
}
// CreateProject menangani POST /projects
func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) {
var req createProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
project, err := h.projectUseCase.CreateProject(r.Context(), req.Name, req.Description, req.OwnerID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(project)
}
// GetProjectsByOwner menangani GET /owners/{ownerID}/projects
func (h *ProjectHandler) GetProjectsByOwner(w http.ResponseWriter, r *http.Request) {
ownerID := r.PathValue("ownerID")
if ownerID == "" {
http.Error(w, "owner ID is required", http.StatusBadRequest)
return
}
projects, err := h.projectUseCase.GetProjectsByOwner(r.Context(), ownerID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(projects)
}
Handler tidak mengandung logika bisnis apapun. Tugasnya hanya parsing request, delegasi ke use case, dan formatting response.
Menghubungkan Semua Lapisan di main.go
Semua lapisan dirakit di titik masuk aplikasi. Inilah tempat dependency injection dilakukan secara eksplisit — tidak ada magic container, tidak ada reflection. Cukup buat instance setiap komponen dan injeksikan dependensinya secara langsung.
// cmd/api/main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
httpdelivery "myproject/internal/delivery/http"
"myproject/internal/infra/postgres"
"myproject/internal/usecase"
)
func main() {
db, err := sql.Open("postgres", "postgres://user:password@localhost/myproject?sslmode=disable")
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
defer db.Close()
// Rakit semua lapisan dari dalam ke luar
projectRepo := postgres.NewProjectPostgresRepo(db)
projectUseCase := usecase.NewProjectUseCase(projectRepo)
projectHandler := httpdelivery.NewProjectHandler(projectUseCase)
mux := http.NewServeMux()
mux.HandleFunc("POST /projects", projectHandler.CreateProject)
mux.HandleFunc("GET /owners/{ownerID}/projects", projectHandler.GetProjectsByOwner)
log.Println("server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Urutan inisialisasi selalu dari lapisan paling dalam ke luar: domain (implisit) → infrastruktur → use case → delivery. Dengan pola ini, jika kamu ingin mengganti PostgreSQL ke MongoDB, hanya file project_repo.go yang perlu diganti — tidak ada yang lain.
Menguji Use Case Tanpa Database
Keuntungan terbesar Clean Architecture baru terasa saat kamu menulis unit test. Karena use case bergantung pada interface, kamu bisa menyuntikkan mock repository tanpa perlu koneksi database.
// internal/usecase/project_usecase_test.go
package usecase_test
import (
"context"
"testing"
"myproject/internal/domain"
"myproject/internal/usecase"
)
// mockProjectRepo adalah implementasi in-memory dari domain.ProjectRepository untuk testing.
type mockProjectRepo struct {
projects map[string]*domain.Project
}
func newMockProjectRepo() *mockProjectRepo {
return &mockProjectRepo{projects: make(map[string]*domain.Project)}
}
func (m *mockProjectRepo) Save(ctx context.Context, p *domain.Project) error {
m.projects[p.ID] = p
return nil
}
func (m *mockProjectRepo) FindByID(ctx context.Context, id string) (*domain.Project, error) {
if p, ok := m.projects[id]; ok {
return p, nil
}
return nil, nil
}
func (m *mockProjectRepo) FindByOwner(ctx context.Context, ownerID string) ([]*domain.Project, error) {
var result []*domain.Project
for _, p := range m.projects {
if p.OwnerID == ownerID {
result = append(result, p)
}
}
return result, nil
}
func (m *mockProjectRepo) Update(ctx context.Context, p *domain.Project) error {
m.projects[p.ID] = p
return nil
}
func (m *mockProjectRepo) Delete(ctx context.Context, id string) error {
delete(m.projects, id)
return nil
}
func TestCreateProject_Success(t *testing.T) {
repo := newMockProjectRepo()
uc := usecase.NewProjectUseCase(repo)
project, err := uc.CreateProject(context.Background(), "Website Redesign", "Redesain tampilan utama", "user-123")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if project.Name != "Website Redesign" {
t.Errorf("expected project name 'Website Redesign', got '%s'", project.Name)
}
if project.Status != domain.StatusActive {
t.Errorf("expected status active, got %s", project.Status)
}
}
func TestCreateProject_EmptyName(t *testing.T) {
repo := newMockProjectRepo()
uc := usecase.NewProjectUseCase(repo)
_, err := uc.CreateProject(context.Background(), "", "Deskripsi proyek", "user-123")
if err == nil {
t.Fatal("expected validation error for empty name, got nil")
}
}
Test ini berjalan dalam milidetik, tidak perlu docker, tidak perlu setup database. Itulah nilai dari pemisahan lapisan yang bersih.
Hal yang Perlu Diperhatikan
Ada beberapa gotcha yang sering ditemui saat pertama kali menerapkan Clean Architecture di Go:
- Jangan terlalu dini membuat abstraksi — jika sebuah use case hanya dipanggil dari satu tempat, mungkin belum perlu dipisahkan. Mulai sederhana, refactor ketika ada kebutuhan nyata.
- Hindari domain yang anemic — domain layer bukan sekadar struct berisi field. Taruh business rule di sana, bukan di use case. Method
Validate()pada contoh di atas adalah contoh yang tepat. - Konsisten dengan arah dependency — aturan terpenting Clean Architecture adalah dependency hanya boleh mengarah ke dalam. Jika lapisan domain terpaksa mengimpor package dari
infra/, ada yang salah dalam desain. - Gunakan
internal/dengan bijak — tidak semua kode perlu berada di bawahinternal/. Package yang memang dirancang untuk digunakan ulang oleh proyek lain bisa ditempatkan di luar.
Kesimpulan
Clean Architecture memberi kamu kebebasan untuk mengganti “detail teknis” — database, HTTP framework, library eksternal — tanpa menyentuh logika bisnis yang sudah teruji. Di Go, pola ini terasa natural karena interface implisit dan komposisi sudah menjadi DNA bahasa ini. Jika kamu sudah familiar dengan Go dan ingin eksplorasi lebih jauh tentang Redis sebagai infrastruktur dalam sistem yang lebih besar, artikel tentang Redis Pub/Sub di Go dengan go-redis bisa menjadi bacaan lanjutan yang relevan.