A mutable registry populated by init(), a session field leaking between evaluations, a validation limit set by a setter, an immutable singleton, and a cached detection result — how to tell which globals are dangerous and which are acceptable.
Not all global state is bad. A var ErrNotFound = errors.New("not found") is global state. So is var controlIDPattern = regexp.MustCompile(...). Nobody argues these should be parameters.
The problem is when global state is mutable and shared across execution boundaries — when one function writes it and another reads it without explicit coordination. In a CLI, this means two evaluations in the same process see each other's side effects. In tests, it means parallel tests corrupt each other's state.
We found 5 kinds of global state in a Go security CLI. Three were dangerous. Two were acceptable. Here's how we decided.
Dangerous: Mutable Global Populated by init()
The Problem
// 14 init() functions across 14 files, all writing to this:
var ControlRegistry = NewRegistry()
// access_block_public.go
func init() {
ControlRegistry.MustRegister(&accessBlockPublic{
Definition: NewDefinition(
WithID("ACCESS.001"),
WithSeverity(policy.SeverityCritical),
),
})
}
// audit_server_logging.go
func init() {
ControlRegistry.MustRegister(&auditServerLogging{ ... })
}
// ... 12 more files
14 init() functions write to a package-level ControlRegistry during import. The consequences:
-
No test isolation. Every test that imports
compliancegets all 14 controls loaded. Can't test with a subset. - No parallel safety. Two parallel tests share the same registry. If one test modifies the registry (registering a test-only control), the other sees the modification.
-
No ordering control. Go doesn't guarantee
init()execution order across files. Adding a control that depends on another being registered first is fragile. -
Hidden side effects. Importing a package changes global state. The import statement
import _ "pkg/compliance"has invisible consequences.
The Fix: Factory Closures + On-Demand Catalogs
var ControlRegistry = NewRegistry()
var allControlConstructors []func() Control
func RegisterControl(factory func() Control) {
allControlConstructors = append(allControlConstructors, factory)
ControlRegistry.MustRegister(factory())
}
// Test-only: create an isolated catalog from the same factories
func NewTestCatalog() *ControlCatalog {
cat := NewRegistry()
for i := range allControlConstructors {
cat.MustRegister(allControlConstructors[i]())
}
return cat
}
// Test-only: create a catalog with specific controls only
func NewCatalogWith(controls ...Control) *ControlCatalog {
cat := NewRegistry()
for _, ctl := range controls {
cat.MustRegister(ctl)
}
return cat
}
Each init() now registers a factory closure instead of a concrete instance:
func init() {
RegisterControl(func() Control {
return &accessBlockPublic{
Definition: NewDefinition(
WithID("ACCESS.001"),
WithSeverity(policy.SeverityCritical),
),
}
})
}
Production is unchanged: ControlRegistry is still populated at import time. Existing code that reads ControlRegistry.Lookup("ACCESS.001") works as before.
Tests are now isolated: NewTestCatalog() creates a fresh catalog by calling each factory — new instances, no shared state. NewCatalogWith(myControl) creates a catalog with just one control for focused testing. Two parallel tests get two independent catalogs.
Dangerous: Session State on a Long-Lived Config Object
The Problem
type Runner struct {
// Configuration (long-lived, set once)
Controls []policy.ControlDefinition
Clock func() time.Time
SLAThreshold time.Duration
// Session state (per-evaluation, mutable) — DANGER
StaveVersion string
InputHashes *evaluation.InputHashes
identitiesByTime map[time.Time][]asset.CloudIdentity
}
identitiesByTime is a mutable map set during Evaluate(). If the Runner is reused for a second evaluation, the identity map from run 1 leaks into run 2. The second evaluation sees identities from snapshots it didn't load.
StaveVersion and InputHashes are per-evaluation metadata on a struct that's supposed to be reusable configuration. Setting them before evaluation and reading them during report assembly creates a temporal coupling — the fields must be set in the right order.
The Fix: Separate by Lifetime
// Configuration (reusable, immutable after construction)
type Assessor struct {
Controls []policy.ControlDefinition
Clock ports.Clock
SLAThreshold time.Duration
}
// Per-call parameters (passed as argument, not stored)
type AssessmentOptions struct {
StaveVersion string
InputHashes *evaluation.InputHashes
}
// Per-evaluation state (created in Assess, garbage collected after)
type assessmentSession struct {
assessor *Assessor
idIndex IdentityIndex // built fresh per evaluation
collector *AssessmentCollector // accumulates per evaluation
auditTime time.Time // computed per evaluation
opts AssessmentOptions
}
identitiesByTime became IdentityIndex — a value type constructed fresh inside Assess() and stored on the session. When Assess() returns, the session is garbage collected. No leaking between evaluations.
The principle: separate by lifetime. Configuration lives for the process. Options live for one call. Session state lives for one evaluation.
Dangerous: Set-Once Global With No Coordination
The Problem
var maxValidationErrors = DefaultMaxValidationErrors
func SetMaxValidationErrors(n int) {
if n > 0 {
maxValidationErrors = n
}
}
A package-level variable set by a Set* function during bootstrap. The intent is "configure once at startup, read during validation." But nothing enforces the "once" part:
- If two tests call
SetMaxValidationErrors(5)andSetMaxValidationErrors(10), the last one wins for both tests. - If validation runs concurrently with
SetMaxValidationErrors, the read is racy. - The function name doesn't communicate that it's a one-time bootstrap operation.
The Fix: Document the Contract
// maxValidationErrors controls how many errors are shown before truncating.
// Set via SetMaxValidationErrors at startup. This is a process-level display
// preference, not domain state — acceptable as a package variable since it's
// set once during bootstrap and read during validation.
var maxValidationErrors = DefaultMaxValidationErrors
// SetMaxValidationErrors overrides the validation error display cap.
// Must be called during process initialization (e.g., bootstrap), not
// concurrently with validation calls.
func SetMaxValidationErrors(n int) {
if n > 0 {
maxValidationErrors = n
}
}
We chose documentation over refactoring here because:
- There's only one caller (
cmd/bootstrap_limits.go) - The variable is a display preference, not domain logic
- Moving it to a parameter would thread it through 5 functions that don't care about it
When this becomes a problem: If a second caller appears, or if tests need different values, refactor to pass the limit through the function call chain or via a ValidationConfig struct.
Acceptable: Immutable Singletons
The Pattern
var SafeResult = ComplianceReport{Exposed: false}
var GlobalScope = &AuditScope{global: true}
var ErrZeroTimestamp = errors.New("record observation: time must not be zero")
var controlIDPattern = regexp.MustCompile(`^CTL\.[A-Z][A-Z0-9]*(\.[A-Z][A-Z0-9]*){1,}\.\d{3}$`)
Why these are fine:
-
Immutable. No
Set*function. No mutation after package initialization.SafeResultis a value type — copying it creates an independent instance. -
No side effects. Reading
GlobalScopedoesn't change state anywhere. -
Compile-time constant behavior.
ErrZeroTimestampalways returns the same error.controlIDPatternalways matches the same strings.
The litmus test: If you can replace the global with a const (conceptually, even if Go doesn't support const for the type), it's safe. SafeResult is conceptually const ComplianceReport{Exposed: false}. ErrZeroTimestamp is conceptually const error("...").
Acceptable: Cached Detection Results
The Pattern
var ttyCache sync.Map
func CanColor(out io.Writer) bool {
f, ok := out.(*os.File)
if !ok {
return false
}
key := reflect.ValueOf(f).Pointer()
if cached, ok := ttyCache.Load(key); ok {
v, _ := cached.(bool)
return v
}
enabled := detectTTY(f)
ttyCache.Store(key, enabled)
return enabled
}
Why this is fine:
-
Idempotent.
detectTTY(f)returns the same result for the same file descriptor. Caching doesn't change behavior — just avoids redundant syscalls. -
Thread-safe.
sync.Maphandles concurrent reads and writes correctly. -
No test pollution. Two tests using
os.Stdoutget the same cached result — which is correct, becauseos.Stdouthas the same TTY status in both tests. TheIsTTY *booloverride onRuntimeallows tests to bypass the cache entirely.
var globalNoColor bool
func SetNoColor(v bool) { globalNoColor = v }
Similar reasoning for globalNoColor: set once during bootstrap, read many times. The --no-color flag is process-level state — it doesn't change during execution. Tests that need different behavior use the Runtime.NoColor field, not the global.
The Classification Framework
| Kind | Mutable? | Set When? | Read When? | Verdict |
|---|---|---|---|---|
ControlRegistry + init()
|
YES | Import time (14 sites) | Runtime (many) | Eliminate — factory pattern |
identitiesByTime on Runner |
YES | Per-evaluation | Same evaluation | Extract — session struct |
maxValidationErrors |
YES | Bootstrap (1 site) | Validation (few) | Document — set-once contract |
SafeResult, ErrZeroTimestamp
|
NO | Package init | Anywhere | Keep — immutable singleton |
ttyCache |
YES (append-only) | First access | Subsequent access | Keep — idempotent cache |
The Three Questions
For each package-level var, ask:
1. Can two callers see different values?
If yes → eliminate. ControlRegistry shows 14 controls to test A and 14 controls to test B, but test A might want only 1.
2. Does mutation in one context leak into another?
If yes → extract. identitiesByTime from evaluation 1 leaked into evaluation 2.
3. Is the value set once and never changed?
If yes → keep (or document). globalNoColor, maxValidationErrors, compiled regexps.
The last question has a trap: set once is only safe if you can guarantee it's called before any reads. In a CLI, bootstrap runs before any command — safe. In a library used by multiple goroutines, set once is a race condition. Know your execution model.
These 5 global state patterns were found in Stave, a Go CLI for offline security evaluation. The ControlRegistry factory pattern enabled parallel test isolation for 14 security controls. The session extraction fixed a state leak between sequential evaluations. The immutable singletons and caches remain — they're the right tool for process-level state that doesn't change.
Top comments (0)