Deepen PracticeOrdered learning track

CLI, Configuration, Secrets, dan Operational Interface

Materi mendalam Go untuk CLI, configuration, secrets, startup validation, operational command, migration command, dan runtime interface service production-grade.

10 min read1947 words
PrevNext
Lesson 2235 lesson track2029 Deepen Practice
#go#golang#cli#configuration+4 more

CLI, Configuration, Secrets, dan Operational Interface

Service production tidak hanya terdiri dari handler, database, dan business logic. Service juga harus bisa dijalankan, dikonfigurasi, diamati, dimigrasikan, dan dihentikan dengan aman.

Di sinilah operational interface menjadi penting.

Operational interface adalah semua cara manusia, CI/CD, platform, dan automation berinteraksi dengan binary kita:

  • command line flags;
  • environment variables;
  • config file;
  • secret source;
  • migration command;
  • health command;
  • version command;
  • startup validation;
  • exit code;
  • logs saat boot;
  • graceful shutdown behavior.

Go sangat kuat di area ini karena menghasilkan binary yang sederhana didistribusikan, punya standard library yang cukup untuk CLI dasar, dan cocok untuk service maupun command-line tooling.


Target Pembelajaran

Setelah menyelesaikan part ini, kita harus mampu:

  1. Mendesain CLI kecil untuk service Go tanpa over-engineering.
  2. Membedakan config, secret, dan runtime state.
  3. Membuat precedence rule configuration yang eksplisit.
  4. Melakukan startup validation sebelum server menerima traffic.
  5. Membuat command serve, migrate, version, dan check-config.
  6. Menangani secret tanpa membocorkannya ke log atau error response.
  7. Mendesain exit code yang berguna untuk automation.
  8. Membuat binary Go yang predictable untuk container dan CI/CD.

Hubungan dengan Framework Kaufman

Dalam kerangka Kaufman, part ini sangat terkait dengan:

  • Remove barriers to practice: environment yang konsisten membuat latihan dan deployment tidak rapuh.
  • Learn enough to self-correct: kita belajar mendeteksi config error saat startup, bukan setelah incident.
  • Deliberate practice: kita membangun operational command nyata.

Banyak engineer belajar bahasa hanya sampai bisa menulis feature. Engineer production-grade harus memastikan feature bisa dijalankan secara aman di lingkungan berbeda.


Mental Model: Binary adalah Produk Operasional

Saat kita menjalankan:

case-api serve --config ./config/local.yaml

binary tidak hanya menjalankan kode. Binary membuat kontrak dengan operator:

  • argumen apa yang diterima;
  • environment variable apa yang dibutuhkan;
  • secret apa yang harus tersedia;
  • validasi apa yang dilakukan;
  • log startup apa yang muncul;
  • kapan exit dengan code non-zero;
  • bagaimana proses berhenti saat menerima signal.

Rule besar:

Jangan biarkan service mulai menerima traffic sebelum configuration, dependency, dan runtime assumption tervalidasi.


Config vs Secret vs State

Sebelum menulis kode, bedakan tiga hal ini.

Config

Config adalah nilai yang mengubah behavior deploy tanpa mengubah kode.

Contoh:

  • HTTP port;
  • database host;
  • log level;
  • timeout;
  • feature flag;
  • max request body size;
  • pagination limit;
  • environment name.

Secret

Secret adalah nilai sensitif yang memberi akses atau otorisasi.

Contoh:

  • database password;
  • API key;
  • private key;
  • signing key;
  • OAuth client secret;
  • token.

State

State adalah data runtime yang berubah karena aplikasi berjalan.

Contoh:

  • cache content;
  • session;
  • database row;
  • idempotency record;
  • queue offset.

Jangan perlakukan state sebagai config. Jangan log secret sebagai config. Jangan compile secret ke binary.


Sumber Configuration

Sumber config umum:

  1. Default value.
  2. Config file.
  3. Environment variable.
  4. CLI flag.
  5. Secret manager.

Precedence harus jelas.

Contoh yang pragmatis:

CLI flag > environment variable > config file > default

Kenapa CLI flag paling tinggi?

Karena operator yang menjalankan command secara eksplisit biasanya ingin override cepat.

Namun untuk service container, banyak organisasi memilih environment variable sebagai interface utama. Itu juga valid. Yang penting adalah konsisten dan terdokumentasi.


Struktur Command

Kita akan membuat binary case-api dengan command:

case-api serve
case-api migrate
case-api check-config
case-api version

Makna:

CommandTujuan
serveMenjalankan HTTP server
migrateMenjalankan database migration
check-configMemvalidasi config tanpa menjalankan server
versionMencetak version/build info

Untuk project kecil, kita bisa memakai standard library flag.

Untuk CLI yang lebih besar, library seperti Cobra sering dipakai. Tetapi penting untuk memahami bentuk dasarnya dulu tanpa framework.


Main yang Tipis

main sebaiknya tipis.

package main

import (
    "context"
    "fmt"
    "os"

    "example.com/caseapi/internal/cli"
)

func main() {
    ctx := context.Background()
    if err := cli.Run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(cli.ExitCode(err))
    }
}

Kenapa Run menerima args, stdout, dan stderr?

Agar bisa dites tanpa menjalankan proses sungguhan.

func TestVersionCommand(t *testing.T) {
    var out bytes.Buffer
    var errOut bytes.Buffer

    err := cli.Run(context.Background(), []string{"version"}, &out, &errOut)
    if err != nil {
        t.Fatalf("Run returned error: %v", err)
    }

    if !strings.Contains(out.String(), "case-api") {
        t.Fatalf("version output = %q", out.String())
    }
}

Rule:

main wiring process-level concerns. Logic command hidup di package yang bisa dites.


Exit Code

Exit code adalah API untuk shell, CI/CD, supervisor, dan Kubernetes job.

Minimal:

CodeMakna
0Success
1General error
2Usage/config error
3Dependency unavailable
4Migration failed

Implementasi:

package cli

type ExitError struct {
    Code int
    Err  error
}

func (e ExitError) Error() string {
    return e.Err.Error()
}

func (e ExitError) Unwrap() error {
    return e.Err
}

func ExitCode(err error) int {
    if err == nil {
        return 0
    }
    var ee ExitError
    if errors.As(err, &ee) {
        return ee.Code
    }
    return 1
}

Usage error:

return ExitError{Code: 2, Err: fmt.Errorf("unknown command %q", cmd)}

Jangan semua error keluar sebagai 1 jika automation perlu membedakan failure class.


Config Struct

Gunakan struct eksplisit.

package config

import "time"

type Config struct {
    Env      string
    HTTP     HTTPConfig
    Database DatabaseConfig
    Logging  LoggingConfig
    Security SecurityConfig
}

type HTTPConfig struct {
    Addr              string
    ReadHeaderTimeout time.Duration
    ReadTimeout       time.Duration
    WriteTimeout      time.Duration
    IdleTimeout       time.Duration
    MaxBodyBytes      int64
}

type DatabaseConfig struct {
    URL             string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
}

type LoggingConfig struct {
    Level string
    JSON  bool
}

type SecurityConfig struct {
    HMACSigningKey string
}

Catatan:

  • Secret masih berupa field agar aplikasi bisa memakai nilainya.
  • Tetapi field secret tidak boleh dicetak mentah.
  • Bisa juga memakai custom type untuk secret agar redaction lebih aman.

Redacted Config

Untuk startup log, kita ingin tahu config apa yang dipakai tanpa membocorkan secret.

type RedactedConfig struct {
    Env      string
    HTTP     HTTPConfig
    Database RedactedDatabaseConfig
    Logging  LoggingConfig
    Security RedactedSecurityConfig
}

type RedactedDatabaseConfig struct {
    URL             string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
}

type RedactedSecurityConfig struct {
    HMACSigningKey string
}

func (c Config) Redacted() RedactedConfig {
    return RedactedConfig{
        Env:  c.Env,
        HTTP: c.HTTP,
        Database: RedactedDatabaseConfig{
            URL:             redactDatabaseURL(c.Database.URL),
            MaxOpenConns:    c.Database.MaxOpenConns,
            MaxIdleConns:    c.Database.MaxIdleConns,
            ConnMaxLifetime: c.Database.ConnMaxLifetime,
        },
        Logging: c.Logging,
        Security: RedactedSecurityConfig{
            HMACSigningKey: redactSecret(c.Security.HMACSigningKey),
        },
    }
}

func redactSecret(s string) string {
    if s == "" {
        return ""
    }
    return "<redacted>"
}

Database URL perlu redaction khusus karena password sering berada di URL:

postgres://user:password@host:5432/db

Jangan log URL mentah.


Default Config

Default harus aman untuk local development, bukan production.

func Default() Config {
    return Config{
        Env: "local",
        HTTP: HTTPConfig{
            Addr:              ":8080",
            ReadHeaderTimeout: 5 * time.Second,
            ReadTimeout:       10 * time.Second,
            WriteTimeout:      30 * time.Second,
            IdleTimeout:       60 * time.Second,
            MaxBodyBytes:      1 << 20,
        },
        Database: DatabaseConfig{
            MaxOpenConns:    10,
            MaxIdleConns:    5,
            ConnMaxLifetime: 30 * time.Minute,
        },
        Logging: LoggingConfig{
            Level: "info",
            JSON:  true,
        },
    }
}

Hati-hati:

  • Jangan set default production secret.
  • Jangan default ke insecure mode untuk production.
  • Jangan default timeout ke nol jika nol berarti no timeout.

Environment Variable Loader

Sederhana dulu:

func ApplyEnv(c *Config, lookup func(string) (string, bool)) error {
    if v, ok := lookup("CASE_ENV"); ok {
        c.Env = v
    }
    if v, ok := lookup("CASE_HTTP_ADDR"); ok {
        c.HTTP.Addr = v
    }
    if v, ok := lookup("CASE_DATABASE_URL"); ok {
        c.Database.URL = v
    }
    if v, ok := lookup("CASE_LOG_LEVEL"); ok {
        c.Logging.Level = v
    }
    if v, ok := lookup("CASE_HMAC_SIGNING_KEY"); ok {
        c.Security.HMACSigningKey = v
    }

    if v, ok := lookup("CASE_HTTP_MAX_BODY_BYTES"); ok {
        n, err := strconv.ParseInt(v, 10, 64)
        if err != nil {
            return fmt.Errorf("CASE_HTTP_MAX_BODY_BYTES must be integer: %w", err)
        }
        c.HTTP.MaxBodyBytes = n
    }

    return nil
}

Kenapa menerima lookup func(string) (string, bool)?

Agar test mudah:

func TestApplyEnv(t *testing.T) {
    cfg := config.Default()
    env := map[string]string{
        "CASE_HTTP_ADDR": ":9090",
    }

    err := config.ApplyEnv(&cfg, func(k string) (string, bool) {
        v, ok := env[k]
        return v, ok
    })
    if err != nil {
        t.Fatal(err)
    }

    if cfg.HTTP.Addr != ":9090" {
        t.Fatalf("addr = %q", cfg.HTTP.Addr)
    }
}

Config File

Config file berguna untuk local development dan environment yang punya banyak parameter.

Contoh YAML:

# config/local.yaml
env: local
http:
  addr: ":8080"
  read_header_timeout: "5s"
  read_timeout: "10s"
  write_timeout: "30s"
  idle_timeout: "60s"
  max_body_bytes: 1048576
database:
  url: "postgres://case:case@localhost:5432/case?sslmode=disable"
  max_open_conns: 10
  max_idle_conns: 5
  conn_max_lifetime: "30m"
logging:
  level: "debug"
  json: false

Go standard library tidak punya YAML parser. Untuk production, kita bisa:

  • memakai JSON/TOML jika ingin dependency minimal;
  • memakai YAML library dengan dependency hygiene;
  • memakai environment-only config.

Contoh JSON config agar standard-library-only:

{
  "env": "local",
  "http": {
    "addr": ":8080",
    "read_header_timeout": "5s",
    "read_timeout": "10s",
    "write_timeout": "30s",
    "idle_timeout": "60s",
    "max_body_bytes": 1048576
  }
}

Karena time.Duration default JSON adalah number nanoseconds, sering lebih manusiawi memakai DTO config file dengan string duration lalu mapping.

type fileConfig struct {
    Env  string `json:"env"`
    HTTP struct {
        Addr              string `json:"addr"`
        ReadHeaderTimeout string `json:"read_header_timeout"`
        ReadTimeout       string `json:"read_timeout"`
        WriteTimeout      string `json:"write_timeout"`
        IdleTimeout       string `json:"idle_timeout"`
        MaxBodyBytes      int64  `json:"max_body_bytes"`
    } `json:"http"`
}

Mapping duration:

func parseDurationField(name, raw string) (time.Duration, error) {
    if raw == "" {
        return 0, nil
    }
    d, err := time.ParseDuration(raw)
    if err != nil {
        return 0, fmt.Errorf("%s must be duration: %w", name, err)
    }
    return d, nil
}

CLI Flags

Untuk serve:

func runServe(ctx context.Context, args []string, stdout, stderr io.Writer) error {
    fs := flag.NewFlagSet("serve", flag.ContinueOnError)
    fs.SetOutput(stderr)

    var configPath string
    var httpAddr string

    fs.StringVar(&configPath, "config", "", "path to config file")
    fs.StringVar(&httpAddr, "http-addr", "", "HTTP listen address")

    if err := fs.Parse(args); err != nil {
        return ExitError{Code: 2, Err: err}
    }

    cfg := config.Default()

    if configPath != "" {
        if err := config.ApplyFile(&cfg, configPath); err != nil {
            return ExitError{Code: 2, Err: err}
        }
    }

    if err := config.ApplyEnv(&cfg, os.LookupEnv); err != nil {
        return ExitError{Code: 2, Err: err}
    }

    if httpAddr != "" {
        cfg.HTTP.Addr = httpAddr
    }

    if err := cfg.Validate(); err != nil {
        return ExitError{Code: 2, Err: err}
    }

    return serve(ctx, cfg, stdout, stderr)
}

Perhatikan precedence:

  1. default;
  2. config file;
  3. env;
  4. flag.

Ini eksplisit dalam kode.


Startup Validation

Startup validation harus menangkap config invalid sebelum service menerima traffic.

func (c Config) Validate() error {
    var errs []error

    if c.Env == "" {
        errs = append(errs, errors.New("env is required"))
    }

    switch c.Env {
    case "local", "dev", "staging", "prod":
    default:
        errs = append(errs, fmt.Errorf("env must be one of local, dev, staging, prod"))
    }

    if c.HTTP.Addr == "" {
        errs = append(errs, errors.New("http.addr is required"))
    }
    if c.HTTP.ReadHeaderTimeout <= 0 {
        errs = append(errs, errors.New("http.read_header_timeout must be > 0"))
    }
    if c.HTTP.MaxBodyBytes <= 0 {
        errs = append(errs, errors.New("http.max_body_bytes must be > 0"))
    }

    if c.Database.URL == "" {
        errs = append(errs, errors.New("database.url is required"))
    }
    if c.Database.MaxOpenConns <= 0 {
        errs = append(errs, errors.New("database.max_open_conns must be > 0"))
    }

    if c.Env == "prod" && c.Security.HMACSigningKey == "" {
        errs = append(errs, errors.New("security.hmac_signing_key is required in prod"))
    }

    return errors.Join(errs...)
}

errors.Join membuat kita bisa mengembalikan beberapa error sekaligus. Ini sangat berguna untuk config validation karena operator ingin melihat semua masalah, bukan memperbaiki satu per satu.

Output buruk:

invalid config

Output lebih baik:

config validation failed:
- database.url is required
- http.read_header_timeout must be > 0
- security.hmac_signing_key is required in prod

Kita bisa membuat formatter khusus untuk error join jika diperlukan.


Secret Handling

Secret harus diperlakukan sebagai nilai berbahaya.

Rule praktis:

  1. Jangan commit secret.
  2. Jangan hardcode secret di source code.
  3. Jangan print secret di startup log.
  4. Jangan return secret di error.
  5. Jangan expose secret di /debug endpoint.
  6. Jangan menyimpan secret di metric label.
  7. Jangan passing secret melalui command line di sistem multi-user jika process list bisa dibaca user lain.
  8. Prefer env var atau secret manager untuk containerized service.
  9. Rotasi secret harus dipikirkan jika service long-running.

Secret Type

Kita bisa membuat type kecil:

type Secret string

func (s Secret) String() string {
    if s == "" {
        return ""
    }
    return "<redacted>"
}

func (s Secret) Value() string {
    return string(s)
}

Lalu config:

type SecurityConfig struct {
    HMACSigningKey Secret
}

Ini mengurangi risiko accidental logging:

fmt.Println(cfg.Security.HMACSigningKey) // <redacted>

Namun tetap hati-hati: conversion ke string mentah masih bisa terjadi melalui Value().


Version Command

Version command membantu debugging production.

case-api version

Output:

case-api version=1.4.2 commit=abc123 built_at=2026-06-27T10:00:00Z go=go1.26.0

Implementasi:

package buildinfo

import "runtime"

var (
    Version = "dev"
    Commit  = "unknown"
    BuiltAt = "unknown"
)

type Info struct {
    Version   string
    Commit    string
    BuiltAt   string
    GoVersion string
}

func Get() Info {
    return Info{
        Version:   Version,
        Commit:    Commit,
        BuiltAt:   BuiltAt,
        GoVersion: runtime.Version(),
    }
}

Inject saat build:

go build \
  -ldflags "-X example.com/caseapi/internal/buildinfo.Version=1.4.2 \
            -X example.com/caseapi/internal/buildinfo.Commit=$(git rev-parse --short HEAD) \
            -X example.com/caseapi/internal/buildinfo.BuiltAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  ./cmd/api

Command:

func runVersion(stdout io.Writer) error {
    info := buildinfo.Get()
    fmt.Fprintf(stdout, "case-api version=%s commit=%s built_at=%s go=%s\n",
        info.Version,
        info.Commit,
        info.BuiltAt,
        info.GoVersion,
    )
    return nil
}

Check Config Command

check-config berguna di CI/CD dan sebelum deploy.

case-api check-config --config ./config/prod.json

Behavior:

  • load config;
  • apply env;
  • validate;
  • print redacted config;
  • exit 0 jika valid;
  • exit 2 jika invalid.
func runCheckConfig(args []string, stdout, stderr io.Writer) error {
    fs := flag.NewFlagSet("check-config", flag.ContinueOnError)
    fs.SetOutput(stderr)

    var configPath string
    fs.StringVar(&configPath, "config", "", "path to config file")

    if err := fs.Parse(args); err != nil {
        return ExitError{Code: 2, Err: err}
    }

    cfg := config.Default()
    if configPath != "" {
        if err := config.ApplyFile(&cfg, configPath); err != nil {
            return ExitError{Code: 2, Err: err}
        }
    }
    if err := config.ApplyEnv(&cfg, os.LookupEnv); err != nil {
        return ExitError{Code: 2, Err: err}
    }
    if err := cfg.Validate(); err != nil {
        return ExitError{Code: 2, Err: fmt.Errorf("config validation failed: %w", err)}
    }

    enc := json.NewEncoder(stdout)
    enc.SetIndent("", "  ")
    return enc.Encode(cfg.Redacted())
}

Ini adalah contoh command kecil yang sangat berguna secara operasional.


Migration Command

Migration sering dipisah dari serve.

case-api migrate --config ./config/prod.json

Kenapa tidak auto-migrate saat server start?

Auto-migration saat startup bisa berbahaya jika:

  • banyak replica start bersamaan;
  • migration butuh lock panjang;
  • migration irreversible;
  • schema change butuh koordinasi dengan deploy bertahap;
  • service mulai menerima traffic saat migration belum aman.

Command migration memberi kontrol lebih eksplisit.

Pola:

func runMigrate(ctx context.Context, args []string, stdout, stderr io.Writer) error {
    cfg, err := loadConfigForCommand(args, stderr)
    if err != nil {
        return ExitError{Code: 2, Err: err}
    }

    db, err := sql.Open("postgres", cfg.Database.URL)
    if err != nil {
        return ExitError{Code: 3, Err: fmt.Errorf("open database: %w", err)}
    }
    defer db.Close()

    ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
    defer cancel()

    if err := db.PingContext(ctx); err != nil {
        return ExitError{Code: 3, Err: fmt.Errorf("ping database: %w", err)}
    }

    if err := migrate.Up(ctx, db); err != nil {
        return ExitError{Code: 4, Err: fmt.Errorf("migration failed: %w", err)}
    }

    fmt.Fprintln(stdout, "migration completed")
    return nil
}

Migration harus:

  • context-aware;
  • punya timeout;
  • punya log jelas;
  • idempotent jika memungkinkan;
  • aman untuk dijalankan ulang;
  • tidak menyembunyikan partial failure.

Serve Command dan Wiring

serve menggabungkan config, dependency, server, dan shutdown.

func serve(ctx context.Context, cfg config.Config, stdout, stderr io.Writer) error {
    logger := newLogger(cfg.Logging, stderr)

    logger.Info("starting case-api", "config", cfg.Redacted())

    db, err := openDatabase(ctx, cfg.Database)
    if err != nil {
        return ExitError{Code: 3, Err: err}
    }
    defer db.Close()

    repo := store.NewPostgres(db)
    svc := caseapp.NewService(repo, realIDGenerator{}, realClock{})
    api := httpapi.NewServer(svc, logger, cfg.HTTP.MaxBodyBytes)

    httpServer := &http.Server{
        Addr:              cfg.HTTP.Addr,
        Handler:           api.Routes(),
        ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout,
        ReadTimeout:       cfg.HTTP.ReadTimeout,
        WriteTimeout:      cfg.HTTP.WriteTimeout,
        IdleTimeout:       cfg.HTTP.IdleTimeout,
    }

    errCh := make(chan error, 1)
    go func() {
        logger.Info("http server listening", "addr", cfg.HTTP.Addr)
        if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            errCh <- err
            return
        }
        errCh <- nil
    }()

    select {
    case <-ctx.Done():
        logger.Info("shutdown requested")
    case err := <-errCh:
        if err != nil {
            return fmt.Errorf("http server failed: %w", err)
        }
        return nil
    }

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := httpServer.Shutdown(shutdownCtx); err != nil {
        return fmt.Errorf("graceful shutdown failed: %w", err)
    }

    logger.Info("shutdown complete")
    return nil
}

Signal handling biasanya di Run atau main layer:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

Jika serve menerima context, test dan caller punya kontrol lifecycle.


Logging Saat Startup

Startup log harus menjawab:

  • binary version apa?
  • environment apa?
  • listen address apa?
  • config apa yang dipakai?
  • dependency apa yang berhasil terkoneksi?
  • apakah migration version cocok?

Namun jangan terlalu bising.

Contoh:

level=INFO msg="starting case-api" version=1.4.2 commit=abc123 env=prod
level=INFO msg="loaded config" http.addr=:8080 database.url="postgres://case:<redacted>@db:5432/case"
level=INFO msg="database connected" max_open_conns=50
level=INFO msg="http server listening" addr=:8080

Hindari:

DATABASE_URL=postgres://case:secret@db:5432/case
HMAC_SIGNING_KEY=supersecret

Operational Config Checklist

Gunakan checklist ini saat review service Go:

  1. Apakah semua config punya owner dan dokumentasi?
  2. Apakah precedence rule jelas?
  3. Apakah config invalid gagal saat startup?
  4. Apakah timeout default aman?
  5. Apakah secret tidak pernah dicetak?
  6. Apakah production wajib mengisi secret?
  7. Apakah binary punya version command?
  8. Apakah ada check-config command?
  9. Apakah migration tidak otomatis berbahaya?
  10. Apakah exit code berguna untuk CI/CD?
  11. Apakah command bisa dites tanpa menjalankan process asli?
  12. Apakah graceful shutdown memakai context dan timeout?
  13. Apakah startup log cukup untuk debugging deploy?
  14. Apakah config type tidak terlalu generic seperti map[string]any?
  15. Apakah environment variable naming konsisten?

Common Anti-pattern

Anti-pattern 1: Global Config Mutable

var Config config.Config

Masalah:

  • test saling mempengaruhi;
  • dependency tersembunyi;
  • race risk;
  • susah menjalankan beberapa instance dalam satu process.

Lebih baik pass config sebagai dependency saat wiring.

Anti-pattern 2: Membaca Env di Mana-mana

func NewDatabase() *sql.DB {
    url := os.Getenv("DATABASE_URL")
    ...
}

Masalah:

  • dependency tidak eksplisit;
  • test sulit;
  • precedence tidak jelas;
  • validation tersebar.

Lebih baik semua env dibaca di config loader.

Anti-pattern 3: Timeout Nol

Banyak zero value Go berguna, tetapi timeout nol sering berarti no timeout. Untuk network service, ini berbahaya.

Pastikan config validation menolak timeout yang tidak sengaja nol.

Anti-pattern 4: Secret di Flag

case-api serve --db-password supersecret

Di banyak sistem, command line process bisa terlihat melalui process list atau audit log. Gunakan env var atau secret manager.

Anti-pattern 5: panic untuk Config Error Biasa

Untuk config invalid, return error dengan exit code jelas. panic cocok untuk programmer bug yang tidak seharusnya terjadi, bukan input operator yang salah.


Testing CLI dan Config

Test config validation:

func TestConfigValidateRequiresDatabaseURL(t *testing.T) {
    cfg := config.Default()
    cfg.Database.URL = ""

    err := cfg.Validate()
    if err == nil {
        t.Fatal("expected validation error")
    }
    if !strings.Contains(err.Error(), "database.url") {
        t.Fatalf("error = %v, want database.url", err)
    }
}

Test command unknown:

func TestUnknownCommand(t *testing.T) {
    var out bytes.Buffer
    var errOut bytes.Buffer

    err := cli.Run(context.Background(), []string{"wat"}, &out, &errOut)
    if err == nil {
        t.Fatal("expected error")
    }
    if code := cli.ExitCode(err); code != 2 {
        t.Fatalf("exit code = %d, want 2", code)
    }
}

Test check-config redaction:

func TestCheckConfigRedactsSecret(t *testing.T) {
    t.Setenv("CASE_DATABASE_URL", "postgres://user:pass@localhost:5432/db")
    t.Setenv("CASE_HMAC_SIGNING_KEY", "secret")

    var out bytes.Buffer
    var errOut bytes.Buffer

    err := cli.Run(context.Background(), []string{"check-config"}, &out, &errOut)
    if err != nil {
        t.Fatal(err)
    }

    got := out.String()
    if strings.Contains(got, "secret") || strings.Contains(got, "pass") {
        t.Fatalf("output leaked secret: %s", got)
    }
}

Testing operational interface sering terasa membosankan, tetapi sangat efektif mencegah incident konfigurasi.


Container Implication

Dalam container, binary Go sering menjadi process utama.

Dockerfile sederhana:

FROM gcr.io/distroless/base-debian12
COPY case-api /case-api
USER nonroot:nonroot
ENTRYPOINT ["/case-api"]
CMD ["serve"]

Karena command default serve, operator masih bisa override:

docker run case-api version
docker run case-api check-config
docker run case-api migrate

Pastikan:

  • binary menangani SIGTERM;
  • logs ke stdout/stderr;
  • config via env/file jelas;
  • tidak butuh shell jika image distroless;
  • command version tetap bisa dijalankan.

Latihan 20 Jam: Slot untuk Operational Interface

Gunakan 2 jam untuk part ini:

WaktuLatihan
15 menitBuat main tipis yang memanggil cli.Run
15 menitTambahkan command version
20 menitBuat Config struct dan default
20 menitBuat env loader yang bisa dites
20 menitBuat validation dengan beberapa error
15 menitBuat redaction untuk secret
20 menitBuat check-config command
20 menitBuat skeleton serve dengan signal context
20 menitTulis test untuk unknown command, config invalid, redaction
15 menitReview operational checklist

Output akhir:

  • binary punya command serve, version, check-config;
  • config loader punya precedence jelas;
  • secret tidak bocor di output;
  • config invalid gagal sebelum service start;
  • command bisa dites.

Kesimpulan

CLI, config, secrets, dan operational interface sering dianggap bagian sekunder. Dalam production engineering, ini bagian utama.

Service yang fiturnya benar tetapi sulit dikonfigurasi tetap berisiko. Service yang gagal saat startup tanpa pesan jelas menyulitkan deploy. Service yang mencetak secret ke log bisa menjadi incident security. Service yang tidak punya version command membuat debugging release lebih lambat.

Go cocok untuk membangun operational interface yang sederhana dan kuat karena:

  • binary mudah didistribusikan;
  • standard library cukup untuk CLI dasar;
  • config dapat dimodelkan dengan struct eksplisit;
  • error handling mendorong startup validation jelas;
  • testing command bisa dilakukan tanpa process nyata.

Prinsip akhirnya:

Treat operational behavior as part of your API.

Di part berikutnya, kita akan masuk ke logging, metrics, tracing, dan observability. Setelah service bisa dikonfigurasi dan dijalankan dengan benar, kita perlu memastikan ia bisa dipahami saat berjalan di production.

Lesson Recap

You just completed lesson 22 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.