Go Coding Standards
File Organization
- One domain per file. CLI commands:
cmd_<domain>.go. Handlers:<domain>.go. main.gois glue only -- root command setup,main(), embed directives, global vars.- Shared CLI helpers go in
client.go(API calls, config) orhelpers.go(utilities). - Keep files under ~500 lines. If a file grows past that, split by domain.
Interface Design
-
Small interfaces. "The bigger the interface, the weaker the abstraction" -- Rob Pike.
-
Domain-scoped sub-interfaces composed into the aggregate:
type SecretStore interface { ... } // ~6 methods
type AuditStore interface { ... } // ~7 methods
type Store interface { SecretStore; AuditStore; ... } -
Functions accept the narrowest interface they need, not the full
Store.
Error Handling
Every error message -- CLI or API -- MUST answer three questions:
- What failed? (the operation)
- Why? (the likely cause)
- What now? (the recovery action)
CLI Error Format: cliError() Card
// WRONG -- bare error, no guidance
return fmt.Errorf("engine not found")
// RIGHT -- structured card with recovery action
cliError("Engine not found",
engineName, // got (what user provided)
"a valid engine name", // expected
"arcan engine list -r "+realm) // fix (actionable command)
After cliError(), silence Cobra and return nil:
cliError(...)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
return nil
HTTP API Error Format by Status Code
// 400 -- tell user exactly what's wrong
jsonError(w, "name is required -- choose a label, e.g. aws-prod or pg-app", 400)
// 401 -- specify which auth method failed
jsonError(w, "invalid or expired token -- re-authenticate with: arcan login", 401)
// 403 -- specify what permission is needed
jsonError(w, "permission denied: secrets:write required in realm prod", 403)
// 404 -- include lookup command
jsonError(w, "engine not found -- list engines with: arcan engine list -r <realm>", 404)
// 409 -- explain conflict and offer alternatives
jsonError(w, "engine 'pg-prod' already exists -- use a different name or delete existing", 409)
// 500 -- operation-specific, details logged internally
internalError(w, "credential generation failed -- check engine connectivity", err)
internalError() automatically:
- Logs the full error with request ID
- Returns
request_idto the client for log correlation - Returns
report_url(pre-filled GitHub issue URL)
Never Leak Internal Errors to HTTP Clients
// WRONG -- exposes library stack traces
jsonError(w, err.Error(), http.StatusBadRequest)
// RIGHT -- controlled message
jsonError(w, "certificate validation failed -- check format and expiry", 400)
Always Wrap Errors with Context
// WRONG
return nil, err
// RIGHT
return nil, fmt.Errorf("getting secret %q: %w", key, err)
Never Swallow Errors Silently
// WRONG
json.Unmarshal(data, &target)
// RIGHT
if err := json.Unmarshal(data, &target); err != nil {
slog.Warn("corrupt JSON", "field", field, "error", err)
}
Exception: json.Marshal on basic Go types (cannot fail in practice).
Always Check crypto/rand.Read Errors
// WRONG
rand.Read(b)
// RIGHT
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating token: %w", err)
}
Handler Pattern
Handlers are thin HTTP adapters:
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
// 1. Resolve realm -- call ResolveRealm() directly
// 2. Decode request -- json.NewDecoder(r.Body).Decode(&req)
// 3. Validate -- check required fields
// 4. Business logic -- store calls, encryption, etc.
// 5. Audit -- dispatch audit event (background context)
// 6. Respond -- jsonResponse(w, data, status)
}
Goroutine Context Rule
// WRONG -- request context cancelled after response sent
go h.dispatcher.Dispatch(r.Context(), event)
// RIGHT -- background context for fire-and-forget
go h.dispatcher.Dispatch(context.Background(), event)
Shared Helpers -- Single Source of Truth
Engine Helpers (internal/engine/shared.go)
engine.SanitizeID(id, maxLen) // Lease ID → safe username
engine.GeneratePassword(bytes) // Crypto-random hex password
CLI Helpers (cmd/arcan/helpers.go)
defaultRealm(realm) // Returns "default" if empty
newTableWriter() // Standard tabwriter
printJSON(v) // Pretty-print JSON
spinner(msg) // Animated progress spinner
countdown(msg, seconds) // Visual countdown bar
prompt(msg) // Read user input from stdin
printKV(key, value) // Formatted key-value output
printStatus(marker, msg) // Status line (checkmark, x, warning)
cliError(msg, got, exp, fix) // Structured error card
Wizard Step Tracker
t := newTracker("Wizard Title", totalSteps)
t.start("Step Name")
t.done("Step Name", "detail")
t.warn("Step Name", "detail")
t.fail("Step Name", "detail")
t.skip("Step Name", "detail")
t.summary("STATUS")
Structured Logging
All server-side code MUST use log/slog -- never log.Printf.
slog.Info("lease expired", "lease_id", id, "engine", engine)
slog.Error("rotation failed", "policy_id", p.ID, "error", err)
See the Structured Logging page for field naming conventions.
Discriminated Unions for Polymorphic Requests
When a request struct varies by type, use json.RawMessage:
type CreateEngineRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Config json.RawMessage `json:"config,omitempty"`
}
Each type gets its own config struct with only the fields it needs.
Testing
- Policy evaluator tests are mandatory. Any change to
internal/policy/must have tests. - Test pure functions directly (no mocks needed).
- Use table-driven tests for exhaustive coverage.
- Store tests use an in-memory SQLite database.
- Plugin tests use a mock ArcanContext (SDK provides test helpers).
- Test file naming:
*_test.goin the same package. - Run
go test ./...before considering any change complete.
Build and Version
Every change must pass:
go build ./... # zero errors
go test ./... # all tests pass
go vet ./... # clean
Build-time version injection:
go build -ldflags="-X main.version=${VERSION} -X main.commit=${COMMIT} -X main.built=${BUILD_TIME}" ./cmd/arcan
Naming Conventions
| Concept | Convention | Example |
|---|---|---|
| Realm (not project) | Used throughout codebase, API, CLI | --realm, /realms/{slug} |
| URL path parameter | {slug} for realm, {engine} for engine name | /realms/{slug}/engines/{engine} |
| Config structs | <Type>Config | PostgresConfig, AWSConfig |
| Token prefixes | arc_ (API), oas_ (session), inv_ (invite) | arc_abc123... |
API Path Convention
All API paths use /realms/{slug}/ prefix for tenant-scoped resources:
/api/v1/realms/{slug}/secrets
/api/v1/realms/{slug}/engines/{engine}/roles/{role}/lease
/api/v1/realms/{slug}/policy/roles
/api/v1/realms/{slug}/audit
Non-tenant-scoped paths:
/api/v1/health
/api/v1/auth/login
/api/v1/auth/register
/api/v1/auth/token
/api/v1/plugins
/api/v1/activate
/metrics
Never use /projects/ -- the codebase uses "realm" everywhere.