State is the Enemy
State is the Enemy
Introduction
You’ve been there. You fix something in one place, and something else breaks three files away. You didn’t touch that file. You didn’t even look at it. But somewhere, it shared state with the one you did.
State is data in your application that sits at rest and changes over time - think a cache, a session, a counter, a database connection. It’s distinct from configuration (which doesn’t change after startup) and from data in transit (a function parameter, a message on a queue). State is the thing your application remembers.
The problem isn’t that state exists. Some state is unavoidable - you’re building software, not a calculator. The problem is state that isn’t managed carefully. It hides. It couples things that shouldn’t be coupled. It makes your code hard to test, hard to refactor, and hard to reason about as soon as there’s more than one goroutine or thread involved.
Why this matters
Testing
Tests that touch shared state are fragile. Run them in isolation and they pass. Run them in a suite and they fail - or worse, they pass when they shouldn’t, because some previous test left the world in just the right shape.
The fix is usually setup and teardown: reset the state before each test, clean up after. This works, but it’s boilerplate you should never have needed to write. Every time you reach for a defer cleanup() in a test, ask yourself whether the function under test actually needed to own that state in the first place.
This is one of the less-discussed reasons TDD tends to produce better designs1: when you write the test first, you immediately feel the pain of any state the function reaches for. That pain is a signal.
Refactoring
State creates hidden coupling. A function that reads from a package-level variable is coupled to everything that writes to that variable - and that coupling isn’t visible in the function signature. You move the function to another package and suddenly it doesn’t compile, or worse, it compiles fine but behaves differently because the variable it depended on is no longer being set.
The function signature is a contract. State that lives outside that contract is a lie of omission.
Concurrency
Goroutines are cheap in Go, and it’s tempting to reach for them early. But shared mutable state and concurrency are a dangerous combination. Two goroutines reading from the same map is fine. Two goroutines where one is writing? That’s a data race, and the Go race detector will find it - but only if you’re running with -race, and only if your tests exercise that code path.
The deeper problem is that locks, the traditional solution, don’t actually remove the complexity. They just serialize it. You still have shared state; you’ve just added coordination on top.
How to manage this
These approaches are roughly in order of preference. Reach for the first one that fits before moving to the next.
Avoid state altogether
The best state is no state. Before you add a cache, ask whether you actually need one. Before you give a service a memory, ask whether it could just recompute. Before you reach for a global, ask whether the function could take a parameter instead.
This isn’t always possible - sometimes you genuinely need to remember something - but it’s surprising how often the instinct to store state is habit rather than necessity.
Pass everything as parameters
If a function needs a value, give it the value. Don’t make it reach for it.
This is the pattern behind dependency injection, and it’s the pattern behind the way I handle time.Now() in Go2. Instead of calling time.Now() directly inside a function (which makes the function untestable without manipulating real time), you pass in the time, or a function that returns the time, as a parameter. The function’s dependencies are explicit in its signature.
// Hard to test - hidden dependency on the clock.
func isExpired(token Token) bool {
return time.Now().After(token.ExpiresAt)
}
// Easy to test - dependency is explicit.
func isExpired(now time.Time, token Token) bool {
return now.After(token.ExpiresAt)
}
The second version is trivial to test. You pass in whatever time you need. No mocking, no monkey-patching, no global state.
Pure functions
A pure function takes parameters and returns a result. It has no side effects, and given the same inputs, it always produces the same output. It doesn’t read from or write to anything outside its parameters and return value.
func totalPrice(orders []Order) Money {
total := Money(0)
for _, o := range orders {
total += o.Price
}
return total
}
No receivers. No globals. Nothing to set up in a test - call it with some orders, check the result. Pure functions are also naturally safe to run concurrently, because they don’t touch shared state by definition.
Not every function can be pure, but the more of your logic you can push into pure functions, the smaller the surface area of your stateful code becomes.
Immutability
Rather than modifying a data structure in place, create a new one with the changes applied and return it. The original is untouched.
// Mutating in place - callers may not expect this.
func (c *Config) WithTimeout(d time.Duration) {
c.Timeout = d
}
// Returning a new value - the original is unchanged.
func (c Config) WithTimeout(d time.Duration) Config {
c.Timeout = d
return c
}
Go copies structs by value, which makes this natural. The second version lets callers chain changes without worrying that they’re modifying shared state. Note that this has a cost at scale - you’re allocating new values rather than mutating existing ones. Profile before committing to this pattern in hot paths3.
Isolate state in one place
If you can’t avoid state, at least keep it together. Put all the mutable fields that belong together into a single struct. The rest of your code - the functions that operate on that struct - can be stateless, taking the struct as a parameter and returning a result.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]Session
}
func (s *SessionStore) Get(id string) (Session, bool) { ... }
func (s *SessionStore) Set(id string, sess Session) { ... }
Everything that touches session state lives in SessionStore. Nothing outside it reads from or writes to sessions directly. If there’s a bug with session state, you know exactly where to look.
Last resort: locks
Locks are not a solution to the problem of shared state. They’re a way of making shared mutable state slightly less dangerous by ensuring only one goroutine touches it at a time. The state is still there. The coupling is still there. You’ve just added a new way to introduce bugs - deadlocks, missed unlocks, and the serialization of work that you started using goroutines to parallelize in the first place.
Go’s sync.Mutex and sync.RWMutex are the standard tools here. If you find yourself reaching for them, first ask whether isolating the state in a single struct (above) already gives you what you need, and whether Go channels might be a better fit for coordinating access across goroutines4.
If you do use locks, keep the critical section as small as possible. Lock, do the minimum, unlock. Don’t call other functions while holding a lock.
Prefer this order
If you’re not sure which approach to use, work down this list and stop at the first one that fits:
- Avoid state - do you need it at all?
- Pass as a parameter - make the dependency explicit.
- Pure functions - push as much logic as possible into stateless code.
- Immutable values - return new structs rather than modifying existing ones.
- Isolate in one struct - keep related state in one place.
- Locks - when you have no other option, and you know why.
The goal isn’t to eliminate state from your codebase - it’s to push it to the edges, make it visible, and keep it contained. The code in the middle should be as stateless as possible. When it is, testing is straightforward, refactoring is safe, and concurrency is a feature rather than a source of bugs.
-
If you’re not already practicing TDD, Why TDD? is worth a read first. ↩︎
-
I covered this pattern in detail in How I mock time in Golang. ↩︎
-
The Go profiler (
go tool pprof) is your friend here. Don’t optimise until you’ve measured. ↩︎ -
If you are unfamiliar with channels and write go then consider Share Memory By Communicating absolutely required reading. ↩︎