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:
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:
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):
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:
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:
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()setelahBegin()-- safe karena Rollback pada committed tx is no-op- Return error ke handler, handler menentukan HTTP status code
JSON Response
Konvensi response JSON:
// 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:
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
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)
-- 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)
-- 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
// 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()
}