Clean Architecture di Go: Panduan Praktis
Go Tutorial Software Architecture #golang #clean-architecture #software-design #project-structure

Clean Architecture di Go: Panduan Praktis

A
Abd. Asis
11 min read
Bagikan:

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.

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.

Untuk project yang lebih besar, pertimbangkan menggunakan testify untuk assertion yang lebih ekspresif, atau mockery untuk generate mock dari interface secara otomatis.

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 bawah internal/. 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.

Referensi

  1. 1How to Write Go Code — Go Documentation
  2. 2go-clean-arch: Go Clean Architecture based on Uncle Bob’s Clean Architecture — GitHub
  3. 3How to implement Clean Architecture in Go — Three Dots Labs
  4. 4Package database/sql — Go Standard Library

Tentang Penulis

Abd. Asis

Abd. Asis

Software Developer dan Laravel Programmer dari Madura, Indonesia. Passionate tentang PHP, Laravel, dan teknologi web modern.

Komentar

Artikel Terkait

Artikel lain yang mungkin menarik untuk kamu