← Kembali ke Blog Golang

Clean Architecture di Go: Pola yang Saya Pakai di 3 Perusahaan

18 Agustus 2026 • 14 min read • by Arif Kurniawan

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:

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:

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."

  1. 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.
  2. Repository interfaces adalah kontrak — define dulu sebelum implementasi. Ini forces kamu untuk think about abstraction, bukan implementation details.
  3. Domain service vs entity methods — kalau behavior melibatkan satu entity, taruh di entity. Kalau melibatkan multiple entities atau external dependencies, taruh di domain service.
  4. Value objects are underrated — bikin type Email string dengan validation itu worth the effort. Membantu catch invalid state di compile time, bukan runtime.
  5. 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. 🚀