Skip to main content

Go Coding Standards

File Organization

  • One domain per file. CLI commands: cmd_<domain>.go. Handlers: <domain>.go.
  • main.go is glue only -- root command setup, main(), embed directives, global vars.
  • Shared CLI helpers go in client.go (API calls, config) or helpers.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:

  1. What failed? (the operation)
  2. Why? (the likely cause)
  3. 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:

  1. Logs the full error with request ID
  2. Returns request_id to the client for log correlation
  3. 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.go in 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

ConceptConventionExample
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>ConfigPostgresConfig, AWSConfig
Token prefixesarc_ (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.