The config that changed its mind
You load a configuration file at startup. You pass that config to your HTTP handler, your database connection pool, and your background worker. Two hours later, the database starts rejecting queries because the port number changed. You didn't write code to change the port. Some library you imported did, or a goroutine overwrote a field while you weren't looking. You need a struct that, once created, cannot be altered.
Go does not have a built-in immutable keyword. You cannot declare a struct and tell the compiler "freeze this value." Instead, Go uses visibility to simulate immutability. A struct is immutable to the outside world if its fields are unexported. Unexported fields start with a lowercase letter. The compiler treats lowercase names as private to the package. If code outside the package tries to touch a lowercase field, the build fails. The struct becomes a sealed box. You can build the box inside the package, hand it out, and no one else can reach in to rearrange the contents.
Immutability in Go is a contract enforced by capitalization, not a runtime lock. The compiler checks the first letter of every identifier and enforces the boundary.
The pattern: unexported fields and a constructor
Here's the simplest implementation. Define the struct with lowercase fields, provide an exported constructor function, and return the struct by value.
// config.go
package config
// config holds connection details.
// Fields are lowercase so external packages cannot modify them.
type config struct {
host string
port int
}
// NewConfig creates a config value with the given parameters.
// It returns a copy of the struct, not a pointer.
func NewConfig(host string, port int) config {
// Validate inputs before creating the value.
if host == "" {
panic("host cannot be empty")
}
// Return the struct by value.
// Callers get a copy they can read but not change.
return config{host: host, port: port}
}
The struct name config is lowercase, which means the type itself is unexported. External packages cannot name this type at all. They can only receive it as a return value from an exported function. This creates an opaque type. The caller knows they have a config, but they cannot construct one, nor can they inspect its fields directly.
The constructor NewConfig is exported because it starts with a capital letter. External code calls config.NewConfig("localhost", 8080) and gets back a value. Since the return type is config, the caller receives a copy. Even if the original package stored the struct somewhere, the caller has their own independent copy. Changing the copy does not affect the original. This follows the Go convention of returning structs rather than pointers. Returning a struct signals that the value is self-contained and safe to copy.
What happens at compile time
When you write code in a different package that tries to access c.host, the compiler rejects it with c.host undefined (type config has no field or method host). The field exists, but the visibility rules hide it. The compiler sees the lowercase h and knows the field belongs to the config package only.
If you forget to export the constructor, external code gets undefined: config.NewConfig. The type is hidden, and the factory function is hidden, so the package exports nothing useful. You must export at least one function that returns the type, or the type is useless outside the package.
If you accidentally capitalize a field, like Port int, the compiler stops protecting you. External code can now read and write c.Port. The immutability contract breaks instantly. Capitalization is the only mechanism. There is no second layer of protection.
The compiler also enforces the return type. If you change NewConfig to return *config, the caller gets a pointer. The caller can still not touch the fields, but they hold a reference to the original struct. If your package later adds a method that mutates the struct, the caller can trigger that mutation through the pointer. Returning the struct by value severs the link. The caller gets a snapshot.
Real usage: getters and validation
Real code needs to read the values. You add getter methods to expose data without allowing modification.
// config.go
package config
import "fmt"
// config holds connection details.
// Fields are unexported to enforce immutability.
type config struct {
host string
port int
}
// NewConfig creates a validated config value.
func NewConfig(host string, port int) (config, error) {
// Validate inputs to ensure the struct is always in a valid state.
if host == "" {
return config{}, fmt.Errorf("host is required")
}
if port < 1 || port > 65535 {
return config{}, fmt.Errorf("port %d is out of range", port)
}
// Return the struct by value.
// The caller gets a copy with unexported fields.
return config{host: host, port: port}, nil
}
// Host returns the host address.
// This method provides read-only access to the internal field.
func (c config) Host() string {
return c.host
}
// Port returns the port number.
func (c config) Port() int {
return c.port
}
Notice the receiver (c config). The method takes the struct by value, not by pointer. This guarantees the method cannot modify the struct. If you used (c *config), the method could change c.host and the change would persist. Using a value receiver is a strong signal that the method is read-only. The receiver name c matches the type config. Go convention prefers short receiver names, usually one or two letters. Avoid this or self.
The constructor now returns an error. This is standard Go practice. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot create an invalid config. The validation happens up front. If the constructor returns an error, the caller knows immediately. This leads to a validity invariant: every instance of the struct is guaranteed to be valid. Callers don't need to check if the config is ready. They just use it.
Here's how external code interacts with the immutable struct. It calls the constructor, checks the error, and reads values through methods.
// main.go
package main
import (
"fmt"
"yourproject/config"
)
func main() {
// Create the config using the exported constructor.
cfg, err := config.NewConfig("localhost", 8080)
if err != nil {
// Handle validation errors immediately.
panic(err)
}
// Read values via getter methods.
// Direct field access is impossible here.
fmt.Println("Connecting to", cfg.Host(), "on port", cfg.Port())
// Attempting to modify the struct fails at compile time.
// cfg.host = "evil.com" // Error: cfg.host undefined
}
Why this matters: concurrency and versioning
Immutable structs solve a concurrency problem without locks. If a struct cannot change, multiple goroutines can read it simultaneously without a mutex. You pass the config to ten handlers, and none of them can corrupt the data. This is why immutable values are preferred for configuration and request-scoped data. You don't need sync.RWMutex to protect a value that cannot be modified. The cost of copying the struct is usually negligible compared to the cost of locking.
This pattern also creates an opaque type, which is a powerful versioning strategy. The caller sees config but knows nothing about its fields. The library author can add a timeout field later, or change port to address, without breaking any calling code. As long as the constructor signature and methods remain the same, the change is invisible. You can evolve the implementation without forcing callers to update their code. The internal layout is an implementation detail.
Some developers reach for map[string]any for configuration. Maps are mutable and lack type safety. You can insert a string where an int is expected, and the error happens at runtime. An immutable struct catches type errors at compile time. You cannot pass a port as a string. The compiler rejects config.NewConfig("host", "8080") with cannot use "8080" (untyped string constant) as int value in argument. Structs provide shape and safety that maps cannot match.
Pitfalls and trade-offs
The most dangerous mistake is returning a pointer. If NewConfig returns *config, the caller holds a reference to the original struct. If your package later adds a setter method, the caller can mutate the value. Always return the struct by value unless you have a specific reason to return a pointer. The compiler error cfg.host undefined only protects you if you hold a value. If you hold a pointer, the compiler still blocks field access, but it does not block method calls that mutate the struct.
Large immutable structs present a trade-off. Value receivers copy the data on every method call. If the struct is megabytes in size, this hurts performance. In that case, you might return a pointer *config and use pointer receivers. However, this breaks the immutability guarantee at the type level. Callers can still call methods that mutate the struct. You rely on documentation and discipline instead of the compiler. For most configs, the struct is small enough that copying is cheaper than the complexity of pointer semantics. Stick to value returns unless profiling shows copying is the bottleneck.
Don't pass a *string. Strings are already cheap to pass by value. If your config contains strings, keep them as string fields. The compiler handles string copying efficiently. Using pointers to strings adds indirection without saving memory.
The compiler won't stop you from exporting a field. It will stop you from touching one you didn't export. Capitalization is your shield. Trust the visibility rules.
When to use this pattern
Use an immutable struct with unexported fields when the value represents a fixed state, like a configuration or a point in time, and you want to prevent accidental mutation across package boundaries.
Use a mutable struct with exported fields when the value changes frequently within the same package and you need direct access for performance or simplicity.
Use an interface when you want to define behavior without exposing the underlying struct, allowing callers to substitute implementations.
Use a pointer to a struct when the struct is large and copying it is expensive, but be careful to document that the value is mutable and avoid exposing setter methods.
Use sync.RWMutex when multiple goroutines need to read and write the same struct concurrently, though immutability is usually a better design for shared state.
Immutability reduces bugs. Mutable state requires discipline. Pick the shape that matches your data's lifecycle.