Clean Architecture #
Setiap sistem yang berumur panjang menghadapi pertanyaan yang sama: bagaimana cara mengubah database tanpa menyentuh business logic? Bagaimana cara menguji use case tanpa menjalankan server? Bagaimana cara menambah channel baru — REST, gRPC, CLI — tanpa menduplikasi logika inti? Pertanyaan-pertanyaan ini tidak bisa dijawab oleh pilihan framework atau database — mereka dijawab oleh arsitektur. Clean Architecture, yang dipopulerkan oleh Robert C. Martin, menjawab semua itu dengan satu aturan fundamental: dependency harus selalu mengalir ke dalam, menuju domain. Framework, database, dan UI adalah detail implementasi yang bisa diganti. Business rule adalah inti yang harus dilindungi dari semua perubahan itu.
Satu Aturan yang Mendefinisikan Segalanya #
Clean Architecture bisa disederhanakan menjadi satu aturan yang disebut Dependency Rule: kode di layer dalam tidak boleh tahu apapun tentang layer luar.
flowchart TD
subgraph CLEAN["Clean Architecture — Dependency Rule"]
FW["Frameworks & Drivers\nWeb, DB, External APIs, UI, CLI"]
IA["Interface Adapters\nControllers, Presenters, Gateways, Repositories"]
UC["Use Cases\nApplication Business Rules"]
EN["Entities\nEnterprise Business Rules"]
end
FW -->|"bergantung pada"| IA
IA -->|"bergantung pada"| UC
UC -->|"bergantung pada"| EN
EN -.->|"tidak bergantung\npada siapapun"| EN
style EN fill:#2d6a4f,color:#fff
style UC fill:#40916c,color:#fff
style IA fill:#74c69d,color:#1b4332
style FW fill:#b7e4c7,color:#1b4332
Arah panah selalu ke dalam. Entity tidak tahu tentang Use Case. Use Case tidak tahu tentang Controller. Controller tidak tahu tentang framework HTTP. Ini bukan sekadar konvensi — ini adalah structural guarantee bahwa business rule terlindungi dari perubahan teknologi.
Jika kamu perlu mengubah database dari PostgreSQL ke MongoDB, hanya lapisan paling luar yang berubah. Jika kamu perlu menambah GraphQL di samping REST, hanya adapter baru yang ditambah. Entity dan Use Case tidak perlu disentuh sama sekali.
Empat Layer dan Tanggung Jawabnya #
Entities — Enterprise Business Rules #
Entity adalah objek yang mengenkapsulasi aturan bisnis paling fundamental — aturan yang berlaku di seluruh perusahaan, bukan hanya di satu aplikasi. Mereka tidak tahu tentang database, HTTP, atau framework apapun.
// Entity: Order — aturan bisnis yang berlaku di semua konteks
package domain
import (
"errors"
"time"
)
type OrderStatus string
const (
StatusPending OrderStatus = "pending"
StatusConfirmed OrderStatus = "confirmed"
StatusShipped OrderStatus = "shipped"
StatusCancelled OrderStatus = "cancelled"
)
// Order adalah entity yang mengenkapsulasi aturan bisnis order
// Tidak ada import dari database, HTTP, atau framework apapun
type Order struct {
id string
customerID string
items []OrderItem
status OrderStatus
total Money
createdAt time.Time
}
type OrderItem struct {
productID string
quantity int
price Money
}
// NewOrder adalah factory function yang menegakkan aturan bisnis
func NewOrder(customerID string, items []OrderItem) (*Order, error) {
if customerID == "" {
return nil, errors.New("customer ID tidak boleh kosong")
}
if len(items) == 0 {
return nil, errors.New("order harus memiliki minimal satu item")
}
total := Money{Amount: 0, Currency: "IDR"}
for _, item := range items {
if item.quantity <= 0 {
return nil, errors.New("quantity harus lebih dari 0")
}
total.Amount += item.price.Amount * int64(item.quantity)
}
return &Order{
id: generateID(),
customerID: customerID,
items: items,
status: StatusPending,
total: total,
createdAt: time.Now(),
}, nil
}
// Confirm mengubah status order — menegakkan invariant bisnis
func (o *Order) Confirm() error {
if o.status != StatusPending {
return errors.New("hanya order pending yang bisa dikonfirmasi")
}
o.status = StatusConfirmed
return nil
}
// Cancel membatalkan order dengan aturan bisnis yang ketat
func (o *Order) Cancel() error {
if o.status == StatusShipped || o.status == StatusCancelled {
return errors.New("order yang sudah dikirim atau dibatalkan tidak bisa dibatalkan")
}
o.status = StatusCancelled
return nil
}
// Getter — entity mengontrol akses ke state internalnya
func (o *Order) ID() string { return o.id }
func (o *Order) CustomerID() string { return o.customerID }
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) Total() Money { return o.total }
func (o *Order) Items() []OrderItem { return append([]OrderItem{}, o.items...) }
Use Cases — Application Business Rules #
Use Case mengorkestrasi alur kerja spesifik satu aplikasi. Ia tahu tentang Entity dan memanggil repository/service melalui interface yang ia definisikan sendiri.
// Use Case: PlaceOrder — alur bisnis spesifik aplikasi ini
package usecase
import (
"context"
"time"
"myapp/domain"
)
// OrderRepository adalah interface yang didefinisikan oleh Use Case
// Implementasinya ada di layer luar (Infrastructure)
// ✓ BENAR: Use Case mendefinisikan kebutuhannya, bukan infrastructure
type OrderRepository interface {
Save(ctx context.Context, order *domain.Order) error
FindByID(ctx context.Context, id string) (*domain.Order, error)
FindByCustomer(ctx context.Context, customerID string) ([]*domain.Order, error)
}
// InventoryService adalah interface untuk mengecek stok
type InventoryService interface {
CheckAndReserve(ctx context.Context, items []domain.OrderItem) error
}
// NotificationService adalah interface untuk mengirim notifikasi
type NotificationService interface {
NotifyOrderPlaced(ctx context.Context, order *domain.Order) error
}
// PlaceOrderInput adalah input DTO untuk use case ini
type PlaceOrderInput struct {
CustomerID string
Items []PlaceOrderItem
}
type PlaceOrderItem struct {
ProductID string
Quantity int
Price domain.Money
}
// PlaceOrderOutput adalah output DTO
type PlaceOrderOutput struct {
OrderID string
Total domain.Money
Status string
CreatedAt time.Time
}
// PlaceOrderUseCase mengimplementasikan logika "pelanggan melakukan pemesanan"
type PlaceOrderUseCase struct {
orderRepo OrderRepository
inventory InventoryService
notification NotificationService
}
func NewPlaceOrderUseCase(
orderRepo OrderRepository,
inventory InventoryService,
notification NotificationService,
) *PlaceOrderUseCase {
return &PlaceOrderUseCase{
orderRepo: orderRepo,
inventory: inventory,
notification: notification,
}
}
// Execute menjalankan use case — tidak tahu tentang HTTP, database, atau framework
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, input PlaceOrderInput) (*PlaceOrderOutput, error) {
// Konversi input ke domain object
items := make([]domain.OrderItem, len(input.Items))
for i, item := range input.Items {
items[i] = domain.OrderItem{
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: item.Price,
}
}
// Buat entity Order — validasi bisnis ada di dalam entity
order, err := domain.NewOrder(input.CustomerID, items)
if err != nil {
return nil, err
}
// Cek dan reservasi stok melalui interface
if err := uc.inventory.CheckAndReserve(ctx, items); err != nil {
return nil, err
}
// Simpan order melalui interface repository
if err := uc.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
// Kirim notifikasi — jika gagal, log saja, jangan gagalkan order
_ = uc.notification.NotifyOrderPlaced(ctx, order)
return &PlaceOrderOutput{
OrderID: order.ID(),
Total: order.Total(),
Status: string(order.Status()),
CreatedAt: time.Now(),
}, nil
}
Interface Adapters — Penerjemah antara Use Case dan Dunia Luar #
Interface Adapter bertugas menerjemahkan data antara format yang dimengerti Use Case (domain object, DTO) dan format yang digunakan dunia luar (JSON, SQL row, gRPC message).
// Controller: HTTP handler yang menerjemahkan HTTP request ke Use Case
package http
import (
"encoding/json"
"net/http"
"myapp/domain"
"myapp/usecase"
)
// PlaceOrderRequest adalah struktur JSON yang diterima dari client
// ✓ Model transport dipisah dari domain entity
type PlaceOrderRequest struct {
CustomerID string `json:"customer_id"`
Items []PlaceOrderItemReq `json:"items"`
}
type PlaceOrderItemReq struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
Price int64 `json:"price"`
}
// PlaceOrderResponse adalah struktur JSON yang dikirim ke client
type PlaceOrderResponse struct {
OrderID string `json:"order_id"`
Total int64 `json:"total"`
Status string `json:"status"`
}
// OrderController bertanggung jawab hanya untuk:
// 1. Parse dan validasi request
// 2. Map ke Use Case input
// 3. Panggil Use Case
// 4. Map output ke response
// TIDAK ada business logic di sini
type OrderController struct {
placeOrderUC *usecase.PlaceOrderUseCase
}
func NewOrderController(placeOrderUC *usecase.PlaceOrderUseCase) *OrderController {
return &OrderController{placeOrderUC: placeOrderUC}
}
func (c *OrderController) PlaceOrder(w http.ResponseWriter, r *http.Request) {
var req PlaceOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "request tidak valid", http.StatusBadRequest)
return
}
// Map HTTP request → Use Case input
items := make([]usecase.PlaceOrderItem, len(req.Items))
for i, item := range req.Items {
items[i] = usecase.PlaceOrderItem{
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: domain.Money{Amount: item.Price, Currency: "IDR"},
}
}
// Panggil Use Case — controller tidak tahu implementasi di baliknya
output, err := c.placeOrderUC.Execute(r.Context(), usecase.PlaceOrderInput{
CustomerID: req.CustomerID,
Items: items,
})
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
// Map Use Case output → HTTP response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(PlaceOrderResponse{
OrderID: output.OrderID,
Total: output.Total.Amount,
Status: output.Status,
})
}
// Repository: implementasi konkret yang mengakses database
package postgres
import (
"context"
"database/sql"
"myapp/domain"
)
// PostgresOrderRepository mengimplementasikan usecase.OrderRepository
// ✓ Interface didefinisikan di Use Case, implementasi ada di sini
type PostgresOrderRepository struct {
db *sql.DB
}
func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *domain.Order) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO orders (id, customer_id, status, total_amount, created_at)
VALUES ($1, $2, $3, $4, $5)`,
order.ID(), order.CustomerID(), order.Status(), order.Total().Amount, time.Now(),
)
return err
}
Struktur Direktori di Go #
Struktur folder yang baik mencerminkan boundary layer Clean Architecture:
myapp/
├── domain/ ← Entities: aturan bisnis paling inti
│ ├── order.go ← Order entity + business rules
│ ├── customer.go
│ ├── money.go ← Value object
│ └── errors.go ← Domain-specific errors
│
├── usecase/ ← Use Cases: alur bisnis aplikasi
│ ├── place_order.go ← PlaceOrderUseCase + interfaces
│ ├── cancel_order.go
│ └── get_order.go
│
├── adapter/ ← Interface Adapters: penerjemah
│ ├── http/
│ │ ├── order_controller.go
│ │ └── router.go
│ ├── grpc/
│ │ └── order_server.go
│ └── dto/
│ └── order_dto.go ← Struct untuk transport layer
│
├── infrastructure/ ← Frameworks & Drivers: detail implementasi
│ ├── postgres/
│ │ └── order_repository.go
│ ├── kafka/
│ │ └── notification_producer.go
│ └── inventory/
│ └── inventory_client.go
│
└── cmd/ ← Entry point + dependency injection
└── server/
└── main.go
Jangan pernah importinfrastructuredaridomainatauusecase. Jika kamu menemukanimport "myapp/infrastructure/postgres"di dalam file domain atau usecase, itu adalah pelanggaran Dependency Rule yang perlu segera diperbaiki. IDE seperti VSCode dengan plugin Go bisa membantu mendeteksi ini, atau kamu bisa menggunakan tool sepertigo-cleanarchuntuk memvalidasinya secara otomatis.
Dependency Injection: Merakit Semua Layer #
Dependency injection adalah mekanisme yang menghubungkan semua layer tanpa melanggar Dependency Rule. File main.go adalah satu-satunya tempat yang diizinkan untuk tahu tentang semua layer sekaligus:
// cmd/server/main.go — satu-satunya tempat yang "tahu segalanya"
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/lib/pq"
adapterhttp "myapp/adapter/http"
"myapp/infrastructure/kafka"
"myapp/infrastructure/postgres"
"myapp/usecase"
)
func main() {
// Inisialisasi infrastruktur
db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
if err != nil {
log.Fatal("gagal koneksi database:", err)
}
defer db.Close()
// Buat implementasi konkret (infrastructure layer)
orderRepo := postgres.NewPostgresOrderRepository(db)
inventorySvc := inventory.NewInventoryClient("http://inventory-service")
notificationSvc := kafka.NewKafkaNotificationProducer("localhost:9092")
// Buat use case dengan dependency injection
// ✓ Use case hanya menerima interface — tidak tahu implementasi konkret
placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, inventorySvc, notificationSvc)
// Buat controller (adapter layer)
orderController := adapterhttp.NewOrderController(placeOrderUC)
// Setup router (framework layer)
mux := http.NewServeMux()
mux.HandleFunc("POST /orders", orderController.PlaceOrder)
log.Println("Server berjalan di :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Pola dependency injection ini bisa divisualisasikan sebagai:
flowchart BT
PG["postgres.OrderRepository\n(implementasi konkret)"]
KF["kafka.NotificationProducer\n(implementasi konkret)"]
IV["inventory.Client\n(implementasi konkret)"]
UC["PlaceOrderUseCase\n(hanya tahu interface)"]
CTRL["OrderController\n(adapter)"]
MAIN["main.go\n(wiring semua dependency)"]
PG -->|"injected as OrderRepository"| UC
KF -->|"injected as NotificationService"| UC
IV -->|"injected as InventoryService"| UC
UC -->|"injected into"| CTRL
MAIN -->|"creates"| PG
MAIN -->|"creates"| KF
MAIN -->|"creates"| IV
MAIN -->|"creates"| UC
MAIN -->|"creates"| CTRL
Pengujian: Kekuatan Utama Clean Architecture #
Salah satu manfaat terbesar Clean Architecture adalah kemampuan menguji setiap layer secara terisolasi:
// usecase/place_order_test.go
package usecase_test
import (
"context"
"testing"
"myapp/domain"
"myapp/usecase"
)
// Mock repository — tidak perlu database nyata
type mockOrderRepository struct {
savedOrders []*domain.Order
}
func (m *mockOrderRepository) Save(ctx context.Context, order *domain.Order) error {
m.savedOrders = append(m.savedOrders, order)
return nil
}
func (m *mockOrderRepository) FindByID(ctx context.Context, id string) (*domain.Order, error) {
return nil, nil
}
func (m *mockOrderRepository) FindByCustomer(ctx context.Context, customerID string) ([]*domain.Order, error) {
return nil, nil
}
// Mock inventory service — tidak perlu HTTP call ke inventory service
type mockInventoryService struct {
shouldFail bool
}
func (m *mockInventoryService) CheckAndReserve(ctx context.Context, items []domain.OrderItem) error {
if m.shouldFail {
return errors.New("stok tidak cukup")
}
return nil
}
// Mock notification service
type mockNotificationService struct{}
func (m *mockNotificationService) NotifyOrderPlaced(ctx context.Context, order *domain.Order) error {
return nil
}
// Test use case tanpa database, tanpa HTTP, tanpa Kafka
func TestPlaceOrder_Success(t *testing.T) {
repo := &mockOrderRepository{}
inventory := &mockInventoryService{}
notification := &mockNotificationService{}
uc := usecase.NewPlaceOrderUseCase(repo, inventory, notification)
output, err := uc.Execute(context.Background(), usecase.PlaceOrderInput{
CustomerID: "customer-123",
Items: []usecase.PlaceOrderItem{
{ProductID: "prod-1", Quantity: 2, Price: domain.Money{Amount: 50000, Currency: "IDR"}},
},
})
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if output.OrderID == "" {
t.Error("expected order ID, got empty string")
}
if output.Total.Amount != 100000 {
t.Errorf("expected total 100000, got %d", output.Total.Amount)
}
if len(repo.savedOrders) != 1 {
t.Errorf("expected 1 saved order, got %d", len(repo.savedOrders))
}
}
func TestPlaceOrder_InsufficientStock(t *testing.T) {
repo := &mockOrderRepository{}
inventory := &mockInventoryService{shouldFail: true} // simulasi stok habis
notification := &mockNotificationService{}
uc := usecase.NewPlaceOrderUseCase(repo, inventory, notification)
_, err := uc.Execute(context.Background(), usecase.PlaceOrderInput{
CustomerID: "customer-123",
Items: []usecase.PlaceOrderItem{
{ProductID: "prod-1", Quantity: 999, Price: domain.Money{Amount: 50000, Currency: "IDR"}},
},
})
if err == nil {
t.Error("expected error for insufficient stock, got nil")
}
if len(repo.savedOrders) != 0 {
t.Error("order tidak boleh disimpan jika stok tidak cukup")
}
}
Dengan mock interface, seluruh use case bisa diuji tanpa satu pun dependency external yang nyata — tidak ada database, tidak ada HTTP call, tidak ada message broker.
Perbandingan dengan Layered Architecture #
Clean Architecture dan Layered Architecture sering disalahartikan sebagai hal yang sama. Ada satu perbedaan kritis:
flowchart LR
subgraph LA["Layered Architecture"]
LA_P["Presentation"] --> LA_A["Application"]
LA_A --> LA_D["Domain"]
LA_D --> LA_DB["Database"]
LA_DB_NOTE["Domain bergantung\npada Database layer"]
end
subgraph CA["Clean Architecture"]
CA_FW["Frameworks & DB"] --> CA_IA["Interface Adapters"]
CA_IA --> CA_UC["Use Cases"]
CA_UC --> CA_EN["Entities"]
CA_EN_NOTE["Entities tidak bergantung\npada siapapun"]
end
| Aspek | Layered Architecture | Clean Architecture |
|---|---|---|
| Arah dependency | Top-down, domain sering bergantung ke DB | Selalu ke dalam, domain tidak bergantung ke DB |
| Testability | Sulit — domain tightly coupled ke DB | Mudah — domain bisa diuji tanpa DB |
| Posisi database | Sering jadi “pusat gravitasi” | Detail implementasi yang bisa diganti |
| Perubahan framework | Bisa merembet ke semua layer | Hanya menyentuh layer luar |
| Learning curve | Rendah | Lebih tinggi |
| Overhead kode | Rendah | Lebih banyak interface dan mapping |
Anti-Pattern yang Harus Dihindari #
// ✗ Import database di domain entity — pelanggaran Dependency Rule
package domain
import "database/sql" // ✗ domain tidak boleh tahu tentang SQL
type Order struct {
db *sql.DB // ✗ entity menyimpan referensi ke database
}
// ✓ Domain entity murni — tidak ada import infrastruktur
package domain
type Order struct {
id string
status OrderStatus
// tidak ada database, tidak ada HTTP, tidak ada framework
}
// ✗ Business logic di controller
func (c *OrderController) PlaceOrder(w http.ResponseWriter, r *http.Request) {
// ✗ validasi bisnis yang seharusnya di domain
if order.Total > 10000000 {
w.WriteHeader(http.StatusBadRequest)
return
}
// ✗ manipulasi state domain langsung dari controller
order.Status = "confirmed"
}
// ✓ Controller hanya parse, delegate ke use case, format response
func (c *OrderController) PlaceOrder(w http.ResponseWriter, r *http.Request) {
var req PlaceOrderRequest
json.NewDecoder(r.Body).Decode(&req)
output, err := c.placeOrderUC.Execute(r.Context(), mapToInput(req)) // ✓ delegate
if err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
json.NewEncoder(w).Encode(mapToResponse(output))
}
// ✗ Memakai struct database langsung sebagai entity
type OrderRow struct { // ✗ ini adalah database model, bukan domain entity
ID int64 `db:"id"`
CustomerID string `db:"customer_id"`
Status string `db:"status"`
}
// ✓ Pisahkan database model dari domain entity
type Order struct { // domain entity — tidak ada tag db
id string
customerID string
status OrderStatus
}
type orderRow struct { // database model — hanya di infrastructure layer
ID int64 `db:"id"`
CustomerID string `db:"customer_id"`
Status string `db:"status"`
}
// ✗ Interface yang terlalu besar di use case — melanggar Interface Segregation
type OrderRepository interface {
Save(...)
FindByID(...)
FindByCustomer(...)
FindByDateRange(...) // ✗ hanya dipakai satu use case
CountByStatus(...) // ✗ hanya dipakai reporting
GenerateReport(...) // ✗ ini bukan tanggung jawab repository
}
// ✓ Interface kecil dan spesifik per use case
type OrderSaver interface { // hanya untuk PlaceOrderUseCase
Save(ctx context.Context, order *domain.Order) error
}
type OrderFinder interface { // hanya untuk GetOrderUseCase
FindByID(ctx context.Context, id string) (*domain.Order, error)
}
Kapan Tidak Menggunakan Clean Architecture #
Gunakan Clean Architecture jika:
✓ Sistem memiliki business rule yang kompleks dan akan terus berkembang
✓ Umur sistem diperkirakan lebih dari 2–3 tahun
✓ Tim cukup besar dan perlu bekerja secara parallel di layer berbeda
✓ Testability adalah prioritas — butuh unit test yang comprehensive
✓ Ada kemungkinan mengganti database atau framework di masa depan
Pertimbangkan pendekatan lebih sederhana jika:
✗ Proyek adalah MVP atau prototype yang belum tentu dilanjutkan
✗ Domain sangat sederhana — CRUD tanpa aturan bisnis yang kompleks
✗ Tim kecil (< 3 developer) — overhead abstraksi lebih besar dari manfaatnya
✗ Deadline sangat ketat dan tidak ada waktu untuk setup awal yang lebih panjang
✗ Tidak ada rencana untuk mengganti database atau menambah channel baru
Checklist Review Clean Architecture #
ENTITIES:
□ Tidak ada import dari database, HTTP, atau framework apapun
□ Validasi bisnis ada di dalam entity, bukan di service atau controller
□ Getter mengontrol akses ke state internal (bukan field public)
□ Factory function (NewX) menegakkan invariant saat pembuatan
USE CASES:
□ Semua dependency berupa interface, bukan struct konkret
□ Interface didefinisikan di package use case, bukan di infrastructure
□ Input dan output menggunakan DTO, bukan domain entity langsung
□ Tidak ada import dari framework HTTP, database, atau messaging
INTERFACE ADAPTERS:
□ Controller tidak mengandung business logic
□ Ada mapping eksplisit antara DTO dan domain object
□ Repository implementation memenuhi interface yang didefinisikan use case
□ Tidak ada domain logic di repository
INFRASTRUCTURE:
□ Hanya layer ini yang boleh import library eksternal
□ Semua implementasi interface dari use case ada di sini
□ Connection pool, retry logic, dan error wrapping ada di sini
DEPENDENCY INJECTION:
□ Wiring semua dependency hanya di main.go
□ Tidak ada global state atau singleton di dalam use case/domain
□ Penggunaan DI container (wire, fx) konsisten jika dipakai
PENGUJIAN:
□ Unit test use case menggunakan mock, tidak butuh database nyata
□ Entity bisa diuji tanpa dependency apapun
□ Integration test ada untuk repository layer
Ringkasan #
- Satu aturan mendefinisikan segalanya — Dependency Rule: kode di layer dalam tidak boleh tahu tentang layer luar; dependency selalu mengalir ke dalam menuju domain.
- Empat layer dengan tanggung jawab yang tegas — Entities (enterprise business rules), Use Cases (application business rules), Interface Adapters (penerjemah), Frameworks & Drivers (detail implementasi).
- Database adalah detail implementasi — bukan pusat sistem; entity dan use case tidak boleh tahu atau bergantung pada database, framework, atau library eksternal apapun.
- Interface didefinisikan oleh use case, bukan oleh infrastructure — use case mendefinisikan kebutuhannya (port), infrastructure menyediakan implementasinya (adapter); inilah yang membuat dependency mengalir ke arah yang benar.
- Controller tidak mengandung business logic — tugasnya hanya parse request, map ke use case input, panggil use case, dan format output; validasi bisnis ada di entity atau use case.
- Model transport dipisah dari domain entity — jangan gunakan struct database atau JSON langsung sebagai domain entity; gunakan DTO untuk layer transport dan pemetaan eksplisit di adapter.
- Testability adalah hasil alami, bukan tambahan — dengan dependency injection berbasis interface, seluruh use case bisa diuji dengan mock tanpa database, HTTP server, atau message broker yang nyata.
- Main.go adalah satu-satunya tempat yang tahu segalanya — wiring semua dependency konkret ke interface hanya terjadi di entry point; tidak ada tempat lain yang boleh tahu tentang semua layer sekaligus.
- Cocok untuk sistem kompleks dengan umur panjang — overhead abstraksi dan boilerplate terbayar seiring pertumbuhan sistem; untuk MVP atau sistem kecil, pertimbangkan pendekatan yang lebih sederhana dulu.
- Bukan template folder, tapi mindset dependency — Clean Architecture bukan tentang nama folder yang benar; ia tentang satu aturan: jangan pernah import dari layer luar ke layer dalam.
← Sebelumnya: Pendahuluan Berikutnya: Domain-Driven Architecture →