Clean Architecture di Go: Pola yang Saya Pakai di 3 Perusahaan
Dulu kami punya service dengan 3000 baris di satu file. Satu perubahan butuh 3 hari regression test. Setelah refactor ke Clean Architecture, tim baru bisa onboard dalam 1 hari dan change failure rate turun 70%.
Kenapa Clean Architecture Cocok untuk Backend Services
Clean Architecture diperkenalkan Uncle Bob (Robert C. Martin) dengan ide sederhana: separation of concerns berdasarkan layer, bukan berdasarkan technical terms. Domain logic tidak boleh tahu apapun soal database, HTTP handlers, atau infrastructure details.
Di Go, ini penting karena:
- Testability — domain logic bisa di-test tanpa mocking infrastructure
- Flexibility — swap PostgreSQL ke MongoDB tanpa ubah business logic
- Onboarding — struktur yang predictable加快 new hire productivity
- Maintainability — karena setiap komponen punya responsibility yang jelas
Di tiga perusahaan yang pernah saya apply — KoinWorks (fintech lending), PrivyID (digital identity), Indodax (crypto exchange) — polanya konsisten:,越大型 sistem, benefit-nya terasa lebih besar.
Folder Structure yang Battle-Tested
Berikut struktur yang sudah prove workable di production:
├── cmd/
│ └── server/
│ └── main.go # Entry point, wire DI
├── internal/
│ ├── domain/ # Enterprise business rules
│ │ ├── entity/ # Domain entities
│ │ │ ├── user.go
│ │ │ └── order.go
│ │ ├── repository/ # Repository interfaces (NOT implementations)
│ │ │ ├── user_repo.go
│ │ │ └── order_repo.go
│ │ ├── service/ # Application business rules
│ │ │ ├── user_service.go
│ │ │ └── order_service.go
│ │ └── vo/ # Value objects
│ │ ├── email.go
│ │ └── money.go
│ ├── infrastructure/ # Frameworks & drivers
│ │ ├── database/
│ │ │ ├── postgres/
│ │ │ │ ├── user_repo_impl.go
│ │ │ │ └── migrations/
│ │ │ └── mysql/
│ │ ├── cache/
│ │ │ └── redis/
│ │ │ └── user_cache.go
│ │ ├── kafka/
│ │ │ └── producer.go
│ │ └── logging/
│ │ └── zap.go
│ └── handler/ # Interface adapters — HTTP/gRPC
│ ├── user_handler.go
│ └── order_handler.go
├── pkg/
│ ├── validator/
│ │ └── validator.go
│ └── response/
│ └── response.go
├── configs/
│ └── config.yaml
├── migrations/
│ └── 001_init.sql
└── tests/
├── unit/
│ ├── user_service_test.go
│ └── mocks/
└── integration/
└── user_repo_test.go
Key principles:
domain/— tidak boleh depend ke folder lain di luardomain/infrastructure/— implementasi detail, depend kedomain/handler/— mengadapt interface daridomain/cmd/— hanya untuk wiring, tidak ada business logic
Repository Pattern untuk Multiple Data Sources
Repository pattern di Go memang tidak se-elegant di bahasa OOP lain karena Go tidak punya generics di versi lama. Tapi dengan Go 1.18+ dan constraint syntax, kita bisa buat generic repository yang tetap type-safe:
// Domain layer — repository interface ( contracts )
package repository
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Save(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter UserFilter) ([]*User, error)
}
// Infrastructure layer — implementation
package postgres
import (
"context"
"database/sql"
"fmt"
"strings"
)
type UserRepositoryImpl struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepositoryImpl {
return &UserRepositoryImpl{db: db}
}
func (r *UserRepositoryImpl) FindByID(ctx context.Context, id string) (*User, error) {
query := `SELECT id, email, name, created_at FROM users WHERE id = $1`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID, &user.Email, &user.Name, &user.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("FindByID: %w", err)
}
return &user, nil
}
func (r *UserRepositoryImpl) Save(ctx context.Context, user *User) error {
query := `
INSERT INTO users (id, email, name, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)`
user.ID = generateUUID() // or ULID for sortability
_, err := r.db.ExecContext(ctx, query,
user.ID, user.Email, user.Name, user.PasswordHash, user.CreatedAt,
)
if err != nil {
if isDuplicateKeyError(err) {
return ErrDuplicateEmail
}
return fmt.Errorf("Save: %w", err)
}
return nil
}
func (r *UserRepositoryImpl) List(ctx context.Context, filter UserFilter) ([]*User, error) {
query := `SELECT id, email, name, created_at FROM users WHERE 1=1`
args := []interface{}{}
argIdx := 1
if filter.Email != "" {
query += fmt.Sprintf(" AND email ILIKE $%d", argIdx)
args = append(args, "%"+filter.Email+"%")
argIdx++
}
if filter.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, filter.Limit)
argIdx++
}
if filter.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, filter.Offset)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("List: %w", err)
}
defer rows.Close()
var users []*User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("List scan: %w", err)
}
users = append(users, &u)
}
return users, nil
}
Kekuatan pattern ini: kita bisa punya multiple implementations. Contoh, untuk testing, kita buat in-memory implementation:
package inmemory
type InMemoryUserRepository struct {
users map[string]*User
mu sync.RWMutex
}
func NewInMemoryUserRepository() *InMemoryUserRepository {
return &InMemoryUserRepository{users: make(map[string]*User)}
}
func (r *InMemoryUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if user, ok := r.users[id]; ok {
return user, nil
}
return nil, repository.ErrNotFound
}
// Semua interface methods implemented...
Dependency Injection di Go Tanpa Framework Berat
Go bukan Java — kita tidak butuh Spring. Wire it manually, tapi dengan cara yang clean:
// cmd/server/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/indodax/crypto-exchange/internal/domain/repository"
"github.com/indodax/crypto-exchange/internal/domain/service"
"github.com/indodax/crypto-exchange/internal/handler"
"github.com/indodax/crypto-exchange/internal/infrastructure/database/postgres"
"github.com/indodax/crypto-exchange/internal/infrastructure/logging"
)
func main() {
// 1. Setup logger
logger := logging.NewZap()
// 2. Setup database
db, err := postgres.NewConnection(postgres.Config{
Host: os.Getenv("DB_HOST"),
Port: os.Getenv("DB_PORT"),
User: os.Getenv("DB_USER"),
Password: os.Getenv("DB_PASSWORD"),
Database: os.Getenv("DB_NAME"),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// 3. Setup repositories (infrastructure layer)
userRepo := postgres.NewUserRepository(db)
orderRepo := postgres.NewOrderRepository(db)
txRepo := postgres.NewTransactionRepository(db)
// 4. Setup cache
cache := redis.NewCache(redis.Config{
Addr: os.Getenv("REDIS_ADDR"),
})
// 5. Setup services (domain layer)
userSvc := service.NewUserService(
userRepo,
cache,
logger,
)
orderSvc := service.NewOrderService(
orderRepo,
txRepo,
logger,
)
// 6. Setup handlers (interface adapters)
userHandler := handler.NewUserHandler(userSvc)
orderHandler := handler.NewOrderHandler(orderSvc)
// 7. Setup router
mux := http.NewServeMux()
registerRoutes(mux, userHandler, orderHandler)
// 8. Start server dengan graceful shutdown
srv := &http.Server{
Addr: ":8080",
Handler: loggingMiddleware(logger, mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("Starting server on :8080")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
logger.Fatal("Server error", err)
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger.Info("Shutting down server...")
if err := srv.Shutdown(ctx); err != nil {
logger.Fatal("Server forced to shutdown", err)
}
}
Tidak butuh framework DI. Dengan constructor functions dan clear dependencies, everything falls into place. Kalau mau automation lebih, google/wire bisa dipakai untuk generate wiring code.
Cross-Cutting Concerns: Logging dan Observability
Cross-cutting concerns di Go最好分散在各层,通过context propagation而不是全局状态:
package logging
import (
"context"
"log/slog"
)
type contextKey string
const loggerKey contextKey = "logger"
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, logger)
}
func FromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
// Usage di service layer:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
logger := logging.FromContext(ctx)
logger.Info("GetUser called", "user_id", id)
user, err := s.userRepo.FindByID(ctx, id)
if err != nil {
logger.Error("GetUser failed", "error", err, "user_id", id)
return nil, err
}
logger.Info("GetUser success", "user_id", id)
return user, nil
}
Untuk structured logging dengan correlation ID (penting untuk distributed tracing):
package middleware
func TracingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-ID")
if traceID == "" {
traceID = generateTraceID()
}
ctx := context.WithValue(r.Context(), traceIDKey, traceID)
ctx = logging.WithLogger(ctx, slog.Default())
w.Header().Set("X-Trace-ID", traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Testing Strategy
Dengan Clean Architecture, testing jadi menyenangkan karena bisa test domain logic tanpa dependencies:
// Unit test untuk service — tidak perlu database, cukup mock repository
package service
import (
"context"
"errors"
"testing"
"github.com/indodax/crypto-exchange/internal/domain/entity"
"github.com/indodax/crypto-exchange/internal/domain/repository"
"github.com/indodax/crypto-exchange/internal/domain/vo"
)
type mockUserRepo struct {
users map[string]*entity.User
}
func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entity.User, error) {
if u, ok := m.users[id]; ok {
return u, nil
}
return nil, repository.ErrNotFound
}
func (m *mockUserRepo) FindByEmail(ctx context.Context, email vo.Email) (*entity.User, error) {
for _, u := range m.users {
if u.Email == email {
return u, nil
}
}
return nil, repository.ErrNotFound
}
func (m *mockUserRepo) Save(ctx context.Context, user *entity.User) error {
m.users[user.ID] = user
return nil
}
func (m *mockUserRepo) Update(ctx context.Context, user *entity.User) error {
m.users[user.ID] = user
return nil
}
func (m *mockUserRepo) Delete(ctx context.Context, id string) error {
delete(m.users, id)
return nil
}
func TestUserService_Register(t *testing.T) {
repo := &mockUserRepo{users: make(map[string]*entity.User)}
logger := slog.New(slog.DiscardHandler)
svc := NewUserService(repo, nil, logger)
ctx := logging.WithLogger(context.Background(), logger)
// Test successful registration
user, err := svc.Register(ctx, RegisterInput{
Email: "test@example.com",
Name: "Test User",
Password: "securepassword123",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if user.Email.String() != "test@example.com" {
t.Errorf("expected email test@example.com, got %s", user.Email)
}
// Test duplicate email
_, err = svc.Register(ctx, RegisterInput{
Email: "test@example.com",
Name: "Another User",
Password: "anotherpassword",
})
if !errors.Is(err, ErrEmailAlreadyExists) {
t.Errorf("expected ErrEmailAlreadyExists, got %v", err)
}
}
Integration test untuk repository layer pakai Docker containers:
// tests/integration/user_repo_test.go
package integration
import (
"context"
"testing"
"github.com/indodax/crypto-exchange/internal/domain/repository"
"github.com/indodax/crypto-exchange/internal/infrastructure/database/postgres"
"github.com/testcontainers/testcontainers-go"
)
func TestUserRepository(t *testing.T) {
ctx := context.Background()
// Start PostgreSQL container
postgresContainer, err := testcontainers.PostgresContainer(ctx,
testcontainers.WithImage("postgres:15-alpine"),
)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := postgresContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %v", err)
}
}()
connStr, err := postgresContainer.ConnectionString(ctx)
if err != nil {
t.Fatal(err)
}
db, err := postgres.NewConnection(postgres.Config{DSN: connStr})
if err != nil {
t.Fatal(err)
}
defer db.Close()
repo := postgres.NewUserRepository(db)
// Run repository tests
t.Run("Save and FindByID", func(t *testing.T) {
user := &entity.User{Email: "test@example.com", Name: "Test"}
if err := repo.Save(ctx, user); err != nil {
t.Fatalf("Save failed: %v", err)
}
found, err := repo.FindByID(ctx, user.ID)
if err != nil {
t.Fatalf("FindByID failed: %v", err)
}
if found.Email != user.Email {
t.Errorf("email mismatch")
}
})
}
Lessons Learned dari 3 Perusahaan
Setelah apply Clean Architecture di KoinWorks (lending platform), PrivyID (eKYC), dan Indodax (crypto exchange), berikut observasi:
"Clean Architecture bukan silver bullet. Tapi untuk tim yang mau scale dan maintain codebase dalam jangka panjang, ini adalah foundation yang solid."
- Tidak perlu over-engineering dari day 1 — mulai dengan struktur sederhana, evolve sesuai kebutuhan. KoinWorks waktu awal hanya 3 layer, berkembang jadi 5+ seiring kompleksitas naik.
- Repository interfaces adalah kontrak — define dulu sebelum implementasi. Ini forces kamu untuk think about abstraction, bukan implementation details.
- Domain service vs entity methods — kalau behavior melibatkan satu entity, taruh di entity. Kalau melibatkan multiple entities atau external dependencies, taruh di domain service.
- Value objects are underrated — bikin
type Email stringdengan validation itu worth the effort. Membantu catch invalid state di compile time, bukan runtime. - Testing accelerate confidence — dengan Clean Architecture, kamu bisa refactor tanpa takut break things. Karena test coverage tinggi, regression test jadi automated.
Perubahan paling signifikan bukan di struktur code — tapi di cara berpikir tim. Engineer mulai think in terms of domain, bukan technical layers. Communication antar tim jadi lebih mudah karena semua bicara same language.
Semoga bermanfaat. Kalau ada pertanyaan soal implementasi specific atau Mau discuss arsitektur lain, feel free to reach out. 🚀