Layered architecture menahan alur panggilan agar bergerak satu arah, tetapi aturan itu masih bisa bocor: tidak ada yang benar-benar melarang lapisan service mengimpor struct request milik framework HTTP. Clean Architecture, dipopulerkan Robert C. Martin (Uncle Bob), memperketatnya menjadi satu aturan penentu: seluruh dependensi harus menunjuk ke dalam, ke inti domain yang murni. Pada chapter ini kita memetakan empat lingkaran Clean Architecture, menerjemahkannya ke Go yang tidak punya class, lalu membangun interactor kecil lengkap dengan gerbang penyimpanannya.

Empat Lingkaran #

Clean Architecture menyusun kode dalam lingkaran konsentris dari dalam ke luar:

LingkaranIsiContoh di Go
EntitiesAturan bisnis paling murniStruct Pengguna + method Valid
Use casesAlur aplikasi, orkestrasi entityInteractor UseCaseDaftar
Interface adaptersPenerjemah antara use case dan dunia luarPresenter, controller, repository adapter
Frameworks & driversDetail teknis yang mudah digantinet/http, driver PostgreSQL, main

Diagram berikut memperlihatkan arah dependensi yang diizinkan. Semua panah menuju lingkaran yang lebih dalam; tidak ada panah yang keluar dari domain:

Inti domain diberi warna paling menonjol karena semua kode di luar boleh mengenalnya, sedangkan domain tidak mengenal siapa pun di luar. Pergantian HTTP ke gRPC, atau PostgreSQL ke PostgreSQL versi lain, tidak pernah menyentuh dua lingkaran terdalam.

Dependency Rule dalam Bahasa Go #

Go tidak punya class dan protected member, tetapi punya alat yang lebih pas untuk arsitektur ini: interface. Batas antar lingkaran dinyatakan sebagai interface yang didefinisikan di lingkaran pemakai, lalu diimplementasikan di lingkaran luar. Use case mendeklarasikan type GerbangPengguna interface { Simpan(Pengguna) error }; adapter database yang berada di lingkaran luar mengimplementasikannya. Panah dependensi pun berbalik arah secara nyata: use case tidak mengimpor package database, justru adapter yang harus memenuhi kontrak use case.

Entity di Clean Architecture juga bebas framework. Struct Pengguna di demo kita hanya string dan satu method validasi; tidak ada tag JSON, tidak ada context.Context, tidak ada *sql.DB. Kemurnian inilah yang membuat entity bisa diuji tanpa alat bantu apa pun.

Demo: Interactor dengan Gerbang Memori #

Berkas clean-demo.go menerapkan alur pendaftaran pengguna dari lingkaran terdalam sampai composition root. Setiap lingkaran ditandai komentar pembatas:

go
package main

import (
	"errors"
	"fmt"
	"strings"
)

// ===== Entities: murni, tanpa framework =====

type Pengguna struct {
	Nama  string
	Email string
}

var ErrEmailKosong = errors.New("email wajib diisi")

func (p Pengguna) Valid() error {
	if strings.TrimSpace(p.Email) == "" {
		return ErrEmailKosong
	}
	return nil
}

// ===== Use case: interactor + gerbang (output port) =====

type GerbangPengguna interface {
	Simpan(p Pengguna) error
}

type UseCaseDaftar struct {
	gerbang GerbangPengguna
}

func (u *UseCaseDaftar) Daftar(nama, email string) (Pengguna, error) {
	p := Pengguna{Nama: nama, Email: email}
	if err := p.Valid(); err != nil {
		return Pengguna{}, err
	}
	if err := u.gerbang.Simpan(p); err != nil {
		return Pengguna{}, fmt.Errorf("gagal menyimpan pengguna: %w", err)
	}
	return p, nil
}

// ===== Interface adapter: presenter dan gerbang memori =====

type PresenterKonsol struct{}

func (PresenterKonsol) Tampil(p Pengguna) {
	fmt.Printf("terdaftar: %s <%s>\n", p.Nama, p.Email)
}

type GerbangMemori struct {
	terdaftar []Pengguna
}

func (g *GerbangMemori) Simpan(p Pengguna) error {
	g.terdaftar = append(g.terdaftar, p)
	return nil
}

// ===== Framework & driver: composition root =====

func main() {
	gerbang := &GerbangMemori{}
	useCase := &UseCaseDaftar{gerbang: gerbang}
	presenter := PresenterKonsol{}

	daftar := [][2]string{
		{"Budi", "[email protected]"},
		{"Siti", ""},
		{"Siti", "[email protected]"},
	}
	for _, d := range daftar {
		hasil, err := useCase.Daftar(d[0], d[1])
		if err != nil {
			fmt.Printf("gagal: %v\n", err)
			continue
		}
		presenter.Tampil(hasil)
	}
	fmt.Printf("total di gerbang memori: %d\n", len(gerbang.terdaftar))
}

Jalankan program dengan perintah berikut dari direktori chapter:

bash
go run clean-demo.go

Output yang Dihasilkan:

text
terdaftar: Budi <[email protected]>
gagal: email wajib diisi
terdaftar: Siti <[email protected]>
total di gerbang memori: 2

Mari kita telaah bagian-bagian penting program di atas:

  1. Baris 11-14 (type Pengguna struct ...): entity hanya berisi data dan tidak punya dependensi. Tidak ada tag json atau db di struct ini; tag-tag itu milik DTO di lingkaran adapter.
  2. Baris 18-23 (func (p Pengguna) Valid ...): aturan bisnis “email wajib diisi” hidup di entity, bukan di handler atau database constraint. Nilai kembalian error membuat aturan ini bisa diuji sebagai unit murni.
  3. Baris 27-29 (type GerbangPengguna interface ...): output port milik use case. Nama “gerbang” menegaskan fungsinya sebagai pintu ke dunia luar yang bentuknya ditentukan lingkaran dalam.
  4. Baris 35-44 (func (u *UseCaseDaftar) Daftar ...): interactor membangun entity, menjalankan validasi, lalu menyimpan lewat gerbang. Seluruh alur pendaftaran bisa dibaca dari method ini tanpa melihat HTTP maupun SQL.
  5. Baris 54-61 (type GerbangMemori ...): adapter penyimpanan di lingkaran luar. Di aplikasi nyata, struct ini diganti adapter PostgreSQL yang membungkus *sql.DB dan db.Exec.
  6. Baris 65-84 (func main ...): lingkaran framework sekaligus composition root — satu-satunya tempat yang boleh mengenal semua bagian dan merakitnya.

Kapan Ini Menjadi Over-engineering #

Clean Architecture membayar ketegasan dependensinya dengan jumlah file dan interface yang lebih banyak. Untuk aplikasi CRUD sederhana dengan satu atau dua tabel, empat lingkaran penuh sering kali lebih mahal daripada manfaatnya; layered architecture biasa sudah memadai. Sebaliknya, ketika domain memiliki banyak aturan bisnis yang berubah sering, dan tim ingin inti domain tetap stabil menghadapi pergantian framework, kejelasan dependency rule terbayar cepat.

Pertanyaan berikutnya setelah dependency menunjuk ke dalam adalah mekanis: bagaimana cara merakit semua bagian ini di satu tempat tanpa framework? Chapter berikutnya membahas dependency injection gaya Go.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

clean-demo.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"strings"
)

// ===== Entities: murni, tanpa framework =====

type Pengguna struct {
	Nama  string
	Email string
}

var ErrEmailKosong = errors.New("email wajib diisi")

func (p Pengguna) Valid() error {
	if strings.TrimSpace(p.Email) == "" {
		return ErrEmailKosong
	}
	return nil
}

// ===== Use case: interactor + gerbang (output port) =====

type GerbangPengguna interface {
	Simpan(p Pengguna) error
}

type UseCaseDaftar struct {
	gerbang GerbangPengguna
}

func (u *UseCaseDaftar) Daftar(nama, email string) (Pengguna, error) {
	p := Pengguna{Nama: nama, Email: email}
	if err := p.Valid(); err != nil {
		return Pengguna{}, err
	}
	if err := u.gerbang.Simpan(p); err != nil {
		return Pengguna{}, fmt.Errorf("gagal menyimpan pengguna: %w", err)
	}
	return p, nil
}

// ===== Interface adapter: presenter dan gerbang memori =====

type PresenterKonsol struct{}

func (PresenterKonsol) Tampil(p Pengguna) {
	fmt.Printf("terdaftar: %s <%s>\n", p.Nama, p.Email)
}

type GerbangMemori struct {
	terdaftar []Pengguna
}

func (g *GerbangMemori) Simpan(p Pengguna) error {
	g.terdaftar = append(g.terdaftar, p)
	return nil
}

// ===== Framework & driver: composition root =====

func main() {
	gerbang := &GerbangMemori{}
	useCase := &UseCaseDaftar{gerbang: gerbang}
	presenter := PresenterKonsol{}

	daftar := [][2]string{
		{"Budi", "[email protected]"},
		{"Siti", ""},
		{"Siti", "[email protected]"},
	}
	for _, d := range daftar {
		hasil, err := useCase.Daftar(d[0], d[1])
		if err != nil {
			fmt.Printf("gagal: %v\n", err)
			continue
		}
		presenter.Tampil(hasil)
	}
	fmt.Printf("total di gerbang memori: %d\n", len(gerbang.terdaftar))
}