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:

CharacteristicExplanation
Final stateAll fields are initialized in the constructor and never change afterward
No settersNo 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]
    end

Implementation: 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 --- Note

Copy-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 final keyword; 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 WithX pattern for transformationsobj.WithName("new") returns a new object with a different name; the original obj is unchanged; this enables expressive chaining.
  • Combine with atomic.Value for 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 CompareAndSwap for 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 Money or Coordinate.
  • 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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact