Immutable Object Pattern #
All the synchronization techniques we have learned — mutex, channels, atomics, sync.Once — share one assumption: data can change, and we need to protect that change. But there is a more radical and often more elegant approach: make data that cannot change at all. If an object is never modified after creation, no race condition is possible — because a race condition is a conflict between read and write operations, and write operations simply never exist. No mutex needed. No lock. No atomic. The data can be shared with thousands of goroutines and read by all of them at once without any coordination. This is the essence of the Immutable Object Pattern: eliminating the need for synchronization by eliminating the root cause — mutability itself.
What Is the Immutable Object Pattern? #
The Immutable Object Pattern is a design approach where an object cannot be changed after it is created. Its entire state is set in the constructor, there are no setters, and no methods modify internal fields.
flowchart LR
Constructor([NewX constructor]) -->|set all fields| Obj[(Immutable Object)]
Obj -->|read only| G1([Goroutine 1])
Obj -->|read only| G2([Goroutine 2])
Obj -->|read only| G3([Goroutine 3])
Obj -->|read only| G4([Goroutine 4])
Note[No locks\\nno coordination\\nno race conditions]Three characteristics define an immutable object:
| Characteristic | Explanation |
|---|---|
| Final state | All fields are initialized in the constructor and never change afterward |
| No setters | No SetX(), Update(), or direct field modification methods |
| Transformations produce new objects | “Changes” are done by creating a new, different object |
In Go, immutability is convention-based — there is no final or const keyword for structs. We enforce it through design discipline: private fields, no setters, and a clear constructor.
Why Immutability Solves Concurrency Problems #
To understand the power of immutability, we first need to understand what causes a race condition.
// ANTI-PATTERN: mutable shared state — the source of race conditions
type MutableConfig struct {
Host string // ✗ public field — anyone can change it
Port int
Timeout int
}
func runWorkers(cfg *MutableConfig) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// Goroutine reads cfg.Host while another goroutine may be writing
fmt.Println(cfg.Host, cfg.Port) // ✗ data race if there is a concurrent writer
}(i)
}
// Another goroutine modifies the config
go func() {
cfg.Host = "new-host" // ✗ concurrent write = race condition
cfg.Port = 9090
}()
wg.Wait()
}
// CORRECT: immutable object — no race condition by definition
type ImmutableConfig struct {
host string // ✓ private field — cannot be changed from outside
port int
timeout int
}
func NewImmutableConfig(host string, port, timeout int) *ImmutableConfig {
return &ImmutableConfig{host: host, port: port, timeout: timeout}
}
func (c *ImmutableConfig) Host() string { return c.host }
func (c *ImmutableConfig) Port() int { return c.port }
func (c *ImmutableConfig) Timeout() int { return c.timeout }
func runWorkersImmutable(cfg *ImmutableConfig) {
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println(cfg.Host(), cfg.Port()) // ✓ safe — no concurrent writer
}(i)
}
wg.Wait()
}
The difference is not just a matter of style — it is a fundamental difference in safety guarantees:
flowchart TD
subgraph MUT["Mutable Object"]
MW([Writer goroutine]) -->|write| MD[(Data)]
MR1([Reader 1]) -->|read| MD
MR2([Reader 2]) -->|read| MD
RC[❌ Race Condition\\npossible]
end
subgraph IMM["Immutable Object"]
IC([Constructor]) -->|set once| ID[(Data)]
IR1([Reader 1]) -->|read| ID
IR2([Reader 2]) -->|read| ID
IR3([Reader 3]) -->|read| ID
OK[✓ Always safe\\nno coordination needed]
endImplementation: Value Object #
The Value Object is the most classic example of an Immutable Object — types like Money, Coordinate, or DateRange that represent a value, not a changing entity.
package main
import (
"fmt"
"math"
)
// Money is an immutable value object representing an amount of money
type Money struct {
amount int64 // in the smallest unit (e.g. cents)
currency string
}
func NewMoney(amount int64, currency string) Money {
if currency == "" {
panic("currency must not be empty")
}
return Money{amount: amount, currency: currency}
}
// Getter methods — read only, do not change state
func (m Money) Amount() int64 { return m.amount }
func (m Money) Currency() string { return m.currency }
func (m Money) String() string {
return fmt.Sprintf("%s %.2f", m.currency, float64(m.amount)/100)
}
// Add produces a new Money — does not modify m
func (m Money) Add(other Money) Money {
if m.currency != other.currency {
panic(fmt.Sprintf("cannot add %s to %s", m.currency, other.currency))
}
return Money{amount: m.amount + other.amount, currency: m.currency}
}
// Subtract produces a new Money — does not modify m
func (m Money) Subtract(other Money) Money {
if m.currency != other.currency {
panic(fmt.Sprintf("cannot subtract %s from %s", m.currency, other.currency))
}
return Money{amount: m.amount - other.amount, currency: m.currency}
}
// Multiply produces a new Money
func (m Money) Multiply(factor float64) Money {
return Money{
amount: int64(math.Round(float64(m.amount) * factor)),
currency: m.currency,
}
}
// Coordinate is an immutable value object for a geographic position
type Coordinate struct {
lat float64
lng float64
}
func NewCoordinate(lat, lng float64) Coordinate {
if lat < -90 || lat > 90 {
panic("latitude must be between -90 and 90")
}
if lng < -180 || lng > 180 {
panic("longitude must be between -180 and 180")
}
return Coordinate{lat: lat, lng: lng}
}
func (c Coordinate) Lat() float64 { return c.lat }
func (c Coordinate) Lng() float64 { return c.lng }
// DistanceTo calculates the distance without changing state
func (c Coordinate) DistanceTo(other Coordinate) float64 {
// Haversine formula (simplified)
dlat := (other.lat - c.lat) * math.Pi / 180
dlng := (other.lng - c.lng) * math.Pi / 180
a := math.Sin(dlat/2)*math.Sin(dlat/2) +
math.Cos(c.lat*math.Pi/180)*math.Cos(other.lat*math.Pi/180)*
math.Sin(dlng/2)*math.Sin(dlng/2)
return 6371 * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) // km
}
func main() {
// Money: operated on without a mutex even across goroutines
price := NewMoney(10000, "IDR") // Rp 100.00
tax := NewMoney(1100, "IDR") // Rp 11.00
total := price.Add(tax) // new object — price and tax unchanged
fmt.Println("Price:", price)
fmt.Println("Tax:", tax)
fmt.Println("Total:", total)
// Can be shared with many goroutines without locks
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
discounted := total.Multiply(0.9) // every goroutine gets a new object
fmt.Printf("[Worker %d] Discounted: %s\n", id, discounted)
}(i)
}
wg.Wait()
}
Note that Money is declared as a value type (not a pointer) — this makes every assignment automatically create a copy, naturally reinforcing immutability.
The Reference Type Problem: A Hidden Trap #
Go has several types that are reference types internally: slices, maps, and pointers. Storing these types inside an immutable object without special handling can silently break immutability.
// ANTI-PATTERN: a slice as a direct field — immutability leaks
type BrokenPermissions struct {
roles []string // ✗ a slice is a reference — callers can modify its contents
}
func NewBrokenPermissions(roles []string) *BrokenPermissions {
return &BrokenPermissions{roles: roles} // ✗ stores a reference to the original slice
}
func (p *BrokenPermissions) Roles() []string {
return p.roles // ✗ returns a reference — callers can modify it
}
// The impact:
roles := []string{"admin", "user"}
perms := NewBrokenPermissions(roles)
roles[0] = "superadmin" // ✗ modifying roles[] also modifies perms.roles!
returned := perms.Roles()
returned[1] = "guest" // ✗ modifies perms.roles from the outside!
// CORRECT: always copy the slice on the way in and on the way out
type SafePermissions struct {
roles []string
}
func NewSafePermissions(roles []string) *SafePermissions {
// Copy on entry — isolates from the original slice
copied := make([]string, len(roles))
copy(copied, roles)
return &SafePermissions{roles: copied} // ✓ stores a copy
}
func (p *SafePermissions) Roles() []string {
// Copy on exit — prevents modification from outside
result := make([]string, len(p.roles))
copy(result, p.roles)
return result // ✓ returns a copy
}
func (p *SafePermissions) HasRole(role string) bool {
for _, r := range p.roles {
if r == role {
return true
}
}
return false
}
The same rule applies to map and pointer values inside structs:
// ANTI-PATTERN: a map as a direct field
type BrokenHeaders struct {
headers map[string]string // ✗ a map is a reference
}
func NewBrokenHeaders(h map[string]string) *BrokenHeaders {
return &BrokenHeaders{headers: h} // ✗ stores a reference
}
// CORRECT: copy the map on entry
type SafeHeaders struct {
headers map[string]string
}
func NewSafeHeaders(h map[string]string) *SafeHeaders {
copied := make(map[string]string, len(h))
for k, v := range h {
copied[k] = v // ✓ copy every entry
}
return &SafeHeaders{headers: copied}
}
func (h *SafeHeaders) Get(key string) (string, bool) {
val, ok := h.headers[key]
return val, ok // a string is a value type — safe to return directly
}
func (h *SafeHeaders) All() map[string]string {
// Copy on exit
result := make(map[string]string, len(h.headers))
for k, v := range h.headers {
result[k] = v
}
return result // ✓ returns a copy
}
The WithX Pattern: Immutable Transformation #
When we need to “change” an immutable object — for example a user changing their name, or a config being updated — the solution is creating a new object with different values. This is often called the WithX pattern.
package main
import "fmt"
// RequestContext is an immutable object carried through the request lifecycle
type RequestContext struct {
requestID string
userID int
traceID string
metadata map[string]string
}
func NewRequestContext(requestID string) *RequestContext {
return &RequestContext{
requestID: requestID,
metadata: make(map[string]string),
}
}
// Getter methods
func (r *RequestContext) RequestID() string { return r.requestID }
func (r *RequestContext) UserID() int { return r.userID }
func (r *RequestContext) TraceID() string { return r.traceID }
func (r *RequestContext) Metadata(key string) string {
return r.metadata[key]
}
// WithUserID returns a new RequestContext with a different userID
// r is not modified at all
func (r *RequestContext) WithUserID(userID int) *RequestContext {
return &RequestContext{
requestID: r.requestID,
userID: userID, // new value
traceID: r.traceID,
metadata: r.copyMetadata(),
}
}
// WithTraceID returns a new RequestContext with a different traceID
func (r *RequestContext) WithTraceID(traceID string) *RequestContext {
return &RequestContext{
requestID: r.requestID,
userID: r.userID,
traceID: traceID, // new value
metadata: r.copyMetadata(),
}
}
// WithMetadata returns a new RequestContext with one additional metadata entry
func (r *RequestContext) WithMetadata(key, value string) *RequestContext {
newMeta := r.copyMetadata()
newMeta[key] = value
return &RequestContext{
requestID: r.requestID,
userID: r.userID,
traceID: r.traceID,
metadata: newMeta, // new map with the additional entry
}
}
func (r *RequestContext) copyMetadata() map[string]string {
if r.metadata == nil {
return make(map[string]string)
}
copied := make(map[string]string, len(r.metadata))
for k, v := range r.metadata {
copied[k] = v
}
return copied
}
func main() {
// Build the context step by step — each step produces a new object
ctx := NewRequestContext("req-abc-123").
WithUserID(42).
WithTraceID("trace-xyz-789").
WithMetadata("source", "mobile").
WithMetadata("version", "2.1.0")
fmt.Printf("RequestID: %s\n", ctx.RequestID())
fmt.Printf("UserID: %d\n", ctx.UserID())
fmt.Printf("TraceID: %s\n", ctx.TraceID())
fmt.Printf("Source: %s\n", ctx.Metadata("source"))
// The original ctx does not change even though we create derived contexts
ctxAdmin := ctx.WithMetadata("role", "admin")
fmt.Printf("\nctxAdmin source: %s\n", ctxAdmin.Metadata("source"))
fmt.Printf("ctx role (unchanged): '%s'\n", ctx.Metadata("role"))
}
Immutable Object + atomic.Value for State That Needs Updating #
A very powerful pattern is combining an Immutable Object with atomic.Value for situations where state needs occasional updates but is read very frequently.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// FeatureFlags is an immutable snapshot of all feature flags
type FeatureFlags struct {
flags map[string]bool
}
func NewFeatureFlags(flags map[string]bool) *FeatureFlags {
// Copy to ensure immutability
copied := make(map[string]bool, len(flags))
for k, v := range flags {
copied[k] = v
}
return &FeatureFlags{flags: copied}
}
func (f *FeatureFlags) IsEnabled(feature string) bool {
return f.flags[feature] // safe to read concurrently — there is no writer
}
func (f *FeatureFlags) All() map[string]bool {
result := make(map[string]bool, len(f.flags))
for k, v := range f.flags {
result[k] = v
}
return result
}
// FeatureFlagStore stores the latest flags snapshot atomically
// Many goroutines can read the current snapshot without locks
// Only one goroutine needs a lock when updating
type FeatureFlagStore struct {
current atomic.Value // stores *FeatureFlags
mu sync.Mutex // only for update operations
}
func NewFeatureFlagStore(initial map[string]bool) *FeatureFlagStore {
s := &FeatureFlagStore{}
s.current.Store(NewFeatureFlags(initial))
return s
}
// Get returns the current flags snapshot — very fast, no locks
func (s *FeatureFlagStore) Get() *FeatureFlags {
return s.current.Load().(*FeatureFlags)
}
// Update replaces the snapshot with a new version — only needs a lock here
func (s *FeatureFlagStore) Update(newFlags map[string]bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.current.Store(NewFeatureFlags(newFlags)) // atomic swap
}
func main() {
store := NewFeatureFlagStore(map[string]bool{
"dark_mode": true,
"new_checkout": false,
"beta_dashboard": true,
})
// Many goroutines read concurrently without locks
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
flags := store.Get() // atomic load — no locks
fmt.Printf("[Worker %d] dark_mode: %v, new_checkout: %v\n",
id, flags.IsEnabled("dark_mode"), flags.IsEnabled("new_checkout"))
}(i)
}
// One goroutine updates occasionally
go func() {
time.Sleep(100 * time.Millisecond)
store.Update(map[string]bool{
"dark_mode": true,
"new_checkout": true, // enabled
"beta_dashboard": false,
})
fmt.Println("[Updater] Flags updated")
}()
wg.Wait()
}
This pattern is a very efficient combination:
flowchart LR
U([Updater]) -->|Lock, create new snapshot| AV[(atomic.Value)]
AV -->|atomic.Load, no locks| R1([Reader 1])
AV -->|atomic.Load, no locks| R2([Reader 2])
AV -->|atomic.Load, no locks| R3([Reader 3])
Note[Immutable snapshot:\\nno lock needed for reads]
AV --- NoteCopy-on-Write: A Variation for Large Collections #
When an immutable object is very large and changes are frequent, copying the entire object every time can be expensive. Copy-on-Write (CoW) is the solution: create a copy only when there is an actual change.
package main
import (
"fmt"
"sync/atomic"
)
// ImmutableList is an immutable list with an append operation
type ImmutableList struct {
items []int
}
func NewImmutableList(items ...int) *ImmutableList {
copied := make([]int, len(items))
copy(copied, items)
return &ImmutableList{items: copied}
}
func (l *ImmutableList) Get(i int) int { return l.items[i] }
func (l *ImmutableList) Len() int { return len(l.items) }
// Append produces a NEW ImmutableList with the additional item
// l is not modified
func (l *ImmutableList) Append(item int) *ImmutableList {
newItems := make([]int, len(l.items)+1)
copy(newItems, l.items)
newItems[len(l.items)] = item
return &ImmutableList{items: newItems} // ✓ new object, l unchanged
}
// CowStore uses copy-on-write for efficient updates
type CowStore struct {
list atomic.Pointer[ImmutableList]
}
func NewCowStore() *CowStore {
s := &CowStore{}
s.list.Store(NewImmutableList())
return s
}
func (s *CowStore) Read() *ImmutableList {
return s.list.Load() // atomic — no locks
}
func (s *CowStore) Append(item int) {
for {
old := s.list.Load()
newList := old.Append(item) // create a copy with the new item
if s.list.CompareAndSwap(old, newList) {
return // ✓ successfully updated
}
// failed — another goroutine already updated, retry
}
}
func main() {
store := NewCowStore()
// Many goroutines write concurrently
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(val int) {
defer wg.Done()
store.Append(val)
}(i)
}
wg.Wait()
list := store.Read()
fmt.Printf("List (%d items):", list.Len())
for i := 0; i < list.Len(); i++ {
fmt.Printf(" %d", list.Get(i))
}
fmt.Println()
}
When Immutable Objects Are Less Appropriate #
Immutable Objects are great for:
✓ Value objects (Money, Coordinate, Color, DateRange)
✓ Configuration that rarely changes
✓ Data sent between goroutines via channels
✓ State snapshots for audit logs or event sourcing
✓ Request contexts carried through the request lifecycle
Consider another approach if:
✗ The object is very large and must be updated often
(allocating a new object on every update can be expensive)
✗ Changes are very granular (one small field changes every millisecond)
✗ The object represents a stateful entity (database connection, file handle)
✗ Memory is limited and garbage collection pressure must be minimized
Anti-Patterns to Avoid #
// ✗ Public fields — anyone can modify from outside
type BrokenConfig struct {
Host string // ✗ can be changed directly: cfg.Host = "attacker.com"
Port int
Secret string
}
// ✓ Private fields with getters
type SafeConfig struct {
host string
port int
secret string
}
func (c *SafeConfig) Host() string { return c.host }
func (c *SafeConfig) Port() int { return c.port }
// secret has no getter — no need to expose it
// ✗ Returning the internal slice directly
func (c *BrokenData) Items() []string {
return c.items // ✗ callers can append or modify
}
// ✓ Returning a copy
func (c *SafeData) Items() []string {
result := make([]string, len(c.items))
copy(result, c.items)
return result // ✓ a copy — modifications do not affect the internals
}
// ✗ Constructor accepting a pointer to a mutable object
type BrokenWrapper struct {
data *ExternalMutable
}
func NewBrokenWrapper(d *ExternalMutable) *BrokenWrapper {
return &BrokenWrapper{data: d} // ✗ the caller can still modify *d
}
// ✓ Extract the needed values at construction time
type SafeWrapper struct {
name string
value int
}
func NewSafeWrapper(d *ExternalMutable) *SafeWrapper {
return &SafeWrapper{
name: d.Name, // ✓ copy the value, not the pointer
value: d.Value,
}
}
// ✗ Methods that modify internal state
func (c *BrokenCounter) Increment() {
c.count++ // ✗ this is not immutable — it is mutable with a misleading name
}
// ✓ Methods that return a new object
func (c *ImmutableCounter) Increment() *ImmutableCounter {
return &ImmutableCounter{count: c.count + 1} // ✓ new object
}
Immutable Object Review Checklist #
STRUCT DESIGN:
□ All fields are private (lowercase)
□ The constructor (NewX) initializes all required fields
□ No setter methods (SetX or mutating methods)
□ "Changes" via transformation produce a new object (the WithX pattern)
REFERENCE TYPES:
□ Slices are copied on entry in the constructor (not storing the original reference)
□ Slices are copied on exit in getters (not returning internal references)
□ Maps are copied on entry and on exit
□ Pointers to mutable objects are not stored as fields
USAGE:
□ Objects are shared between goroutines without mutexes
□ Combined with atomic.Value for periodic updates
□ The CoW pattern is considered for large collections updated frequently
DOCUMENTATION:
□ Package/struct comments state that this type is immutable
□ The immutability contract is documented at the constructor
□ Warnings are given if there are fields that could be misinterpreted
TESTING:
□ Tested with go test -race to ensure no data races
□ Tests verify that the object does not change after transformation
□ Benchmarks measure the new-object allocation overhead of WithX operations
Summary #
- Immutable Objects eliminate race conditions fundamentally — if there is no write operation, there is no reader/writer conflict; no mutex, lock, or atomic needed at all.
- In Go, immutability is a convention — there is no
finalkeyword; we enforce it through private fields, no setters, and a constructor that controls initialization.- Three main implementation rules — fields must be private, no setters or methods modifying internal fields, and “changes” are done by creating new objects.
- Reference types are a trap — slices, maps, and pointers inside structs are not automatically immutable; always copy when receiving in the constructor and when returning in getters.
- The
WithXpattern for transformations —obj.WithName("new")returns a new object with a different name; the originalobjis unchanged; this enables expressive chaining.- Combine with
atomic.Valuefor state that needs occasional updates but is read very frequently — immutable snapshot + atomic swap is a very efficient pattern.- Copy-on-Write for large collections — instead of copying the whole collection every time, create a copy only when there is a change and use
CompareAndSwapfor safe concurrent updates.- Value types reinforce immutability — declaring a type as a value (not a pointer) makes every assignment automatically create a copy; great for small types like
MoneyorCoordinate.- Immutable Objects are the best primitive for message passing — data sent between goroutines via channels should ideally be immutable; no coordination needed after receipt.
- Allocation overhead is a trade-off to measure — for small objects that rarely change, immutability is nearly free; for large objects that change often, consider RWMutex or other patterns.
← Previous: Double-Checked Locking Next: Guarded Suspension →