A year ago I landed a staff engineer role building cloud-based lending solutions in Go, and I fell hard for the language. The simplicity is intoxicating: one loop syntax, errors as structs, the elegant (value, error) two-return pattern (deal with errors now!), and a single deployable binary. It gave me C vibes, and I mean that as a compliment.

(Side note: I've shipped everything from .Net to Java to TypeScript. My first love was C, I could flow state it before LSPs and autocomplete. Go does the same, it is becoming my next C)

At work, I was tasked with performance improvements on some chatty microservices that were drowning in redundant database calls and inter-service requests. Classic gravitational pull to distributed monolith situation. So we built a request-based caching layer middleware that injects a request ID into the context, initializes a cache group for the request, then passes it through handlers → services → repositories, then cleans up the cache group at the end of the request. It has no distributed side effects, each replica data is not corrupted or stale, a very short lived cache. It works great. We're talking tens of thousands round trips savings per day!

I was letting it simmer on a few rebases and rerunning integrations tests on each merge to master to see if anything would break. Everything was looking great, then a rebase came in and broke it :( a few tests started to fail, WTF!

The Problem

The cache is in-memory and runs in-process, so the structs are shared. The following code is for example purposes only, it sux, here's the bug:

type Mortgage struct {
    ID      int
    Product *Product
}

// Repository caches a pointer to the mortgage
func (r *Repository) GetMortgage(ctx context.Context, tenant, mortgageID int) (*Mortgage, error) {
    cacheKey := fmt.Sprintf(GetMortgageCacheKey, tenant, mortgageID)
    // Try to get from cache
    cachedValue, err := r.cache.Get(ctx, cacheKey)
    if err == nil && cachedValue != nil {
        if val, ok := cachedValue.(*Mortgage); ok {
            log.G(ctx).Debug("cache hit: ", cacheKey, " method: ", "GetByID")
            return val, nil
        }
    }
    // Call decorated repository
    result, err := r.decorated.GetMortgage(ctx, tenant, mortgageID)
    if err != nil {
    	return mp1, err
    }
    // Cache the result
    r.cache.Set(ctx, cacheKey, result)
    return result, nil
}

Then in the service layer, someone mutates it:

func (m *MortgageService) GetMortgageWithoutInvestor(ctx context.Context, tenant, mortgageID int) (*Mortgage, error) {
		// we get with Investor
    mortgage, _ := m.Repository.GetMortgage(ctx, tenant, mortgageID)

    if mortgage.Product.Investor != nil {
        // do stuff
    }
    // nil the investor because the function says so
    mortgage.Product.Investor = nil  // whoops, corrupted the cache
    return mortgage
}

Next time someone calls the repository's GetMortgage and expects the investor to be there? Nope. It's nil because we mutated the cached pointer. shared ... mutable ... state. Cache corrupted.

One thing to remember, this solution needs to be drop in, general, and not intrusive to the current code. Also we did not want to introduce the complexity of distributed caching. So don't say it ...

Go Doesn't Care About Immutability

My first instinct was obvious: just dereference everything. But then you've got nested pointers, which means dereferencing all the way down the object graph, becomes a dereferencer jig. Deep copy? Sure, except:

  • JSON marshal/unmarshal is messy (legacy code with ignore JSON annotations means non-deterministic deep copy)
  • Gob is worse it doesn't handle interface{}, and pointers well and requires type registration
  • Deep copying complex types generically is hard.

I spent way too much time arguing with Go about this. Other languages have an option to handle Immutability. Go just... doesn't.

The Fix

We ended up with a pragmatic solution: generate a hash when setting a cache value, check it on retrieval. If the object changed, the hash changes, return an error, invalidate the key, and repopulate. Worked like a charm. No overhead with copying.

I will post blog on the actual solution used. TBD!

Conclusion

So how do you get immutability in go? The only answer is you can't, not truly!

The Real Problem (Another Post)

Look, let's be honest, the actual issue is architectural. This "microservice" is really a monolith. Proper service boundaries, less anemic shared models, minimal cross-service dependencies? That would eliminate most of this chattiness. We wouldn't be making seven calls to fetch a single domain object. We wouldn't have repositories/services scattered across layers sharing mutable state.

But that's a massive refactoring. It takes time, buy-in, and it doesn't ship performance improvements today. So we took the pragmatic path: drop in caching, detect mutations, invalidate, and move on. It's a win. This is the reality of scaling software. Architecture decisions that were fine at 10k LOC become disasters at 500k LOC. By then you're patching symptoms instead of fixing roots. That's a different post though.

Appendix Still Curious Though

Another approach to get immutable cache would be to deep copy into the cache and deep copy out of the cache, or always use value types, but the latter is not a drop in solution.

Lets benchmark different approaches to deep copying structs and see what is the overhead cost?

Wether this bench mark is optimized or 100% accurate, I don't care, it is just to give me a mental frame to measure by:

https://github.com/latebit-io/deepcopybenchmark

This was run on M1 mac, with 24 gigs of ram.

DEEPCOPY BENCHMARK COMPARISON

TOKEN (Simple struct with map):
  Manual:      223 ns/op (2.2385ms total for 10000 ops)
  Reflection:  390 ns/op (3.900209ms total for 10000 ops)
  JSON:        4944 ns/op (49.4425ms total for 10000 ops)
  GOB:         20869 ns/op (208.69125ms total for 10000 ops)

USER (Complex struct with pointers, slice, map):
  Manual:      165 ns/op (1.653459ms total for 10000 ops)
  Reflection:  285 ns/op (2.850375ms total for 10000 ops)
  JSON:        5493 ns/op (54.931125ms total for 10000 ops)
  GOB:         23838 ns/op (238.382416ms total for 10000 ops)

DEVICE (Simple struct, no pointers):
  Manual:      1 ns/op (15.459µs total for 10000 ops)
  Reflection:  155 ns/op (1.551625ms total for 10000 ops)
  JSON:        1854 ns/op (18.549ms total for 10000 ops)
  GOB:         14558 ns/op (145.588416ms total for 10000 ops)

Next steps, I may implement an immutable in memory request cache just for fun. In general I think it might be still faster than redis.





If you would like to add a comment in the comment section do a pull request on file: immutable-go.html


                clone:
                gh repo clone latebit-io/latebit
                [email protected]:latebit-io/latebit.git
                https://github.com/latebit-io/latebit.git