How returning pointers to internal slices, exposing mutable globals, and using pointer receivers on read-only methods let callers corrupt state in a Go CLI — and the exact fixes.
Go doesn't have private or protected. It has exported (uppercase) and unexported (lowercase). This makes encapsulation feel optional.
I audited my Go CLI project for encapsulation violations and found 10. Each one let a caller reach into a struct's internal state and mutate it — accidentally or intentionally. The mutations were silent: no error, no panic, no log. The struct just stopped working correctly.
Here are the 6 encapsulation issues I found, with the exact before/after code from the fix.
Pattern 1: Returning the Backing Slice
The Violation
// BEFORE: Caller gets a reference to the internal slice
func (c *Catalog) List() []ControlDefinition {
return c.controls // ← returns the backing array
}
A caller that does controls := catalog.List() gets a direct reference to c.controls. Appending, sorting, or modifying controls mutates the catalog's internal state:
controls := catalog.List()
controls[0].ID = "HACKED" // ← silently corrupts the catalog
No copy was made. The caller and the catalog share the same memory.
The Fix
// AFTER: Caller gets their own copy
func (c *Catalog) List() []ControlDefinition {
return slices.Clone(c.controls)
}
One function call. slices.Clone creates a shallow copy. The caller can sort, filter, or modify their copy without affecting the catalog.
The same fix was applied to ControlParams.Raw():
// BEFORE
func (p *ControlParams) Raw() map[string]any {
return p.params // ← caller can delete keys
}
// AFTER
func (p *ControlParams) Raw() map[string]any {
return maps.Clone(p.params)
}
And to constructor inputs:
// BEFORE: Constructor stores the caller's slice directly
func NewEvaluator(cidrs []string) *Evaluator {
return &Evaluator{trustedCIDRs: cidrs} // ← caller can mutate after construction
}
// AFTER: Constructor clones the input
func NewEvaluator(cidrs []string) *Evaluator {
return &Evaluator{trustedCIDRs: slices.Clone(cidrs)}
}
Rule: If a method returns a slice or map, and the caller shouldn't mutate the source, return slices.Clone or maps.Clone.
Pattern 2: Returning Pointers to Internal State
The Violation
// BEFORE: Returns pointer into internal slice
func (s *Snapshot) FindAsset(id string) *Asset {
for i := range s.Assets {
if s.Assets[i].ID == ID(id) {
return &s.Assets[i] // ← pointer to internal element
}
}
return nil
}
The returned *Asset points directly into s.Assets[i]. Modifying the returned asset modifies the snapshot:
asset := snapshot.FindAsset("my-bucket")
asset.Properties["public"] = false // ← silently modifies the snapshot
This is dangerous in a security tool. An evaluation engine that fixes an asset's properties during analysis would corrupt the snapshot for subsequent controls.
The Fix
// AFTER: Returns a copy, not a pointer
func (s *Snapshot) FindAsset(id string) (Asset, bool) {
assetID := ID(id)
for i := range s.Assets {
if s.Assets[i].ID == assetID {
return s.Assets[i], true // ← value copy
}
}
return Asset{}, false
}
Two changes:
- Returns
Assetby value instead of*Asset— the caller gets a copy - Returns
(Asset, bool)instead of*Asset— the comma-ok pattern instead of nil-checking
Every caller changed from pointer-check to comma-ok:
// BEFORE
asset := snapshot.FindAsset("bucket-1")
if asset == nil { ... }
// AFTER
asset, ok := snapshot.FindAsset("bucket-1")
if !ok { ... }
Pattern 3: Pointer Receivers on Read-Only Methods
The Violation
// BEFORE: Pointer receiver on a method that only reads
func (s *ObservationStats) HasFirstObservation() bool {
return !s.firstSeenAt.IsZero()
}
func (s *ObservationStats) CoverageSpan() time.Duration {
return s.coverageSpan
}
func (s *ObservationStats) ObservationCount() int {
return s.observationCount
}
These methods only read fields — they never modify s. But the pointer receiver signals "this method might mutate" and prevents the struct from being used as a value in certain contexts.
More importantly, when Timeline.Stats() returned *ObservationStats, callers could modify the stats through the pointer, invalidating the timeline's internal state.
The Fix
// AFTER: Value receivers — these methods are read-only
func (s ObservationStats) HasFirstObservation() bool {
return !s.firstSeenAt.IsZero()
}
func (s ObservationStats) CoverageSpan() time.Duration {
return s.coverageSpan
}
func (s ObservationStats) ObservationCount() int {
return s.observationCount
}
And the parent method returns by value:
// BEFORE: Returns pointer to internal stats
func (t *Timeline) Stats() *ObservationStats {
return &t.stats
}
// AFTER: Returns copy
func (t *Timeline) Stats() ObservationStats {
return t.stats
}
The caller's stats is now a snapshot of the timeline's state at the time of the call. Mutations don't flow back.
The same fix was applied to Timeline.History():
// BEFORE
func (t *Timeline) History() *EpisodeHistory { return &t.history }
// AFTER
func (t *Timeline) History() EpisodeHistory { return t.history }
With EpisodeHistory methods switched from pointer to value receivers:
func (h EpisodeHistory) Count() int { return len(h.episodes) }
func (h EpisodeHistory) RecurringViolationCount(w kernel.TimeWindow) int { ... }
func (h EpisodeHistory) WindowSummary(w kernel.TimeWindow) (count int, first, last time.Time) { ... }
Pattern 4: Exported Field Protecting Internal Invariant
The Violation
// BEFORE: Exported field lets callers bypass sync.Once initialization
type ExceptionConfig struct {
Rules []ExceptionRule // ← exported, directly mutable
index map[kernel.ControlID][]*ExceptionRule
once sync.Once
}
ExceptionConfig uses sync.Once to build an index from Rules on first access. If a caller modifies Rules after the index is built, the index is stale. The lookup returns wrong results — silently.
cfg := NewExceptionConfig(rules)
cfg.Rules = append(cfg.Rules, newRule) // ← index is now stale
cfg.ShouldExcept(controlID, assetID) // ← returns wrong answer
The Fix
// AFTER: Private field with accessor returning defensive copy
type ExceptionConfig struct {
rules []ExceptionRule // ← unexported
index map[kernel.ControlID][]*ExceptionRule
once sync.Once
}
func (c *ExceptionConfig) Rules() []ExceptionRule {
if c == nil {
return nil
}
return slices.Clone(c.rules)
}
The rules field is now lowercase (unexported). The Rules() accessor returns a defensive copy. The caller can inspect rules but cannot corrupt the index.
Rule: If a struct uses sync.Once or any lazy-initialization pattern, the fields it initializes from must be unexported.
Pattern 5: Mutable Package-Level Variables
The Violation
// BEFORE: Package-level var — any caller can mutate
var EvaluatableTypes = []ControlType{
TypeUnsafeState,
TypeUnsafeDuration,
TypeUnsafeRecurrence,
}
// Any package can do this:
controldef.EvaluatableTypes[0] = TypeUnknown // ← corrupts global state
Three package-level var slices were exported. Any caller could modify the global state, affecting every other consumer.
The Fix
// AFTER: Function returns a fresh slice every time
func EvaluatableTypes() []ControlType {
return []ControlType{
TypeUnsafeState,
TypeUnsafeDuration,
TypeUnsafeRecurrence,
}
}
var → func. Each caller gets their own slice. No shared mutable state.
The same pattern was applied to S3IngestPermissions and KnownIncompatible:
// BEFORE
var S3IngestPermissions = []string{"s3:GetObject", "s3:ListBucket"}
// AFTER
func S3IngestPermissions() []string {
return []string{"s3:GetObject", "s3:ListBucket"}
}
Rule: If a package-level variable is a slice or map, make it a function. var says "shared mutable state." func says "here's your own copy."
Pattern 6: Construction Without Defense
The Violation
// BEFORE: Constructor stores caller's slice directly
func NewEvaluator(cidrs []string) *Evaluator {
return &Evaluator{
trustedCIDRs: cidrs, // ← same backing array as caller
}
}
// Caller
cidrs := []string{"10.0.0.0/8"}
eval := NewEvaluator(cidrs)
cidrs[0] = "0.0.0.0/0" // ← eval.trustedCIDRs is now "0.0.0.0/0"
The constructor stores the caller's slice without cloning. Post-construction mutations by the caller silently affect the evaluator.
The Fix
// AFTER: Clone at construction boundary
func NewEvaluator(cidrs []string) *Evaluator {
return &Evaluator{
trustedCIDRs: slices.Clone(cidrs),
}
}
The evaluator owns its own copy from the moment of construction. The caller can't affect it afterward.
When to Encapsulate
Not every field needs encapsulation. Here's when each fix applies:
| Situation | Fix | Cost |
|---|---|---|
| Method returns internal slice/map |
slices.Clone / maps.Clone
|
Allocation per call |
| Method returns pointer into slice | Return by value + comma-ok | Copy per call |
| Read-only method with pointer receiver | Switch to value receiver | None (often faster) |
Exported field guards lazy init (sync.Once) |
Unexport + accessor | One new method |
Package-level var slice/map |
Convert to func
|
None |
| Constructor stores caller's slice |
slices.Clone at construction |
One allocation |
The cost is almost always one allocation. The benefit is that callers can't corrupt internal state — a class of bugs that is silent, non-reproducible, and extremely hard to diagnose.
These 6 patterns were identified in the Stave refactoring catalog, fixing 10 violations.
Top comments (0)