Skip to content

Coding Standards

Konvensi dan pattern yang digunakan di codebase Go RetailOS.

Go Patterns

chi/v5 Router

RetailOS menggunakan go-chi/chi/v5 sebagai HTTP router. Semua route didefinisikan di router.go:

go
import "github.com/go-chi/chi/v5"

func New(deps Dependencies) http.Handler {
    r := chi.NewRouter()

    // Middleware stack
    r.Use(corsMiddleware)
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Recoverer)

    // Route groups
    r.Route("/api/pos", func(r chi.Router) {
        r.Use(apiKeyAuthMiddleware(deps.APIKeys))
        r.Post("/transactions", deps.POSTransaction.CreateHandler)
        r.Get("/transactions/{id}", deps.POSTransaction.GetHandler)
    })

    return r
}

Handlers Struct Pattern

Setiap module meng-expose handler melalui struct yang menerima dependencies via constructor:

go
type Handlers struct {
    db *sql.DB
}

func NewHandlers(db *sql.DB) *Handlers {
    return &Handlers{db: db}
}

func (h *Handlers) List(w http.ResponseWriter, r *http.Request) {
    rows, err := h.db.QueryContext(r.Context(),
        "SELECT id, name FROM items WHERE status = $1", "active")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()
    // ... scan rows dan encode JSON response
}

Kenapa pattern ini:

  • Tidak ada global state -- semua dependencies explicit
  • Mudah di-test (inject mock db)
  • Setiap module independen

Dependencies Injection

Router top-level menerima Dependencies struct yang berisi semua module handlers. Module yang nil akan di-skip (tidak di-mount ke router):

go
if deps.AccountingHandler != nil {
    r.Route("/api/v1/accounting", func(r chi.Router) {
        r.Get("/coa", deps.AccountingHandler.ListAccounts)
        r.Post("/journals", deps.AccountingHandler.CreateJournal)
    })
}

Pattern ini berfungsi sebagai feature flag gratis -- module yang belum siap cukup tidak di-instantiate.

shopspring/decimal

Semua kalkulasi moneter menggunakan shopspring/decimal di Go code, dan NUMERIC(18,4) di PostgreSQL:

go
import "github.com/shopspring/decimal"

subtotal := decimal.NewFromFloat(0)
for _, item := range items {
    lineTotal := decimal.NewFromFloat(item.Price).
        Mul(decimal.NewFromInt(int64(item.Qty)))
    subtotal = subtotal.Add(lineTotal)
}

// Pembulatan ke ratusan terbawah (Rp 100)
rounded := subtotal.Div(decimal.NewFromInt(100)).
    Floor().
    Mul(decimal.NewFromInt(100))

Catatan

Jangan gunakan float64 untuk kalkulasi harga, diskon, atau pajak. Selalu gunakan decimal.Decimal.

Error Handling

RetailOS menggunakan standard Go error handling tanpa custom error framework:

go
func (m *Manager) ProcessPayment(txnID string, amount float64) error {
    tx, err := m.db.Begin()
    if err != nil {
        return fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback()

    // ... business logic

    if err := tx.Commit(); err != nil {
        return fmt.Errorf("commit payment %s: %w", txnID, err)
    }
    return nil
}

Pattern:

  • Wrap errors dengan context menggunakan fmt.Errorf("...: %w", err)
  • defer tx.Rollback() setelah Begin() -- safe karena Rollback pada committed tx is no-op
  • Return error ke handler, handler menentukan HTTP status code

JSON Response

Konvensi response JSON:

go
// Success
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "success": true,
        "data":    data,
    })
}

// Error
func respondError(w http.ResponseWriter, status int, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]interface{}{
        "success": false,
        "error":   map[string]string{"message": message},
    })
}

UUIDv7

Semua IDs menggunakan UUIDv7 (time-sortable UUID) dari package pkg/uuidv7:

go
import "github.com/retailos/backbone/pkg/uuidv7"

transactionID := uuidv7.New()  // e.g. "01904d6b-1234-7abc-..."

Context Usage

Request context digunakan untuk:

  • Request ID (X-Request-ID)
  • User/cashier identity
  • Store ID
  • Cancellation
go
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
    storeID := chi.URLParam(r, "store_id")
    cashierID := r.Context().Value(ctxKeyCashierID).(string)
    // ...
}

SQL Conventions

PostgreSQL (Cloud Hub)

sql
-- Gunakan NUMERIC(18,4) untuk monetary values
CREATE TABLE acct_journal_lines (
    debit  NUMERIC(18,4) NOT NULL DEFAULT 0,
    credit NUMERIC(18,4) NOT NULL DEFAULT 0
);

-- Gunakan TIMESTAMPTZ untuk semua timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()

-- Index pada kolom yang sering di-query
CREATE INDEX idx_txn_store_date ON transactions(store_id, created_at);

SQLite (Store Router)

sql
-- Gunakan REAL untuk monetary values (SQLite tidak punya decimal type)
-- Kalkulasi presisi dilakukan di Go code dengan shopspring/decimal
total_amount REAL NOT NULL DEFAULT 0

-- Gunakan TEXT untuk timestamps (ISO 8601)
created_at TEXT NOT NULL DEFAULT (datetime('now'))

-- WAL mode untuk concurrent reads
PRAGMA journal_mode=WAL;

Frontend Conventions

TypeScript / React

  • TanStack Router untuk routing (bukan React Router)
  • TanStack Query untuk data fetching
  • Tailwind CSS untuk styling (bukan CSS modules)
  • shadcn/ui sebagai component library
  • Vite sebagai build tool

API Client Pattern

typescript
// lib/api.ts
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'

export async function fetchJSON<T>(path: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${API_BASE}${path}`, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': getApiKey(),
      ...options?.headers,
    },
  })
  if (!res.ok) throw new Error(`API error: ${res.status}`)
  return res.json()
}

RetailOS - Sistem ERP Retail Modern untuk Indonesia