Context
Twin WMS & FMS was transitioning into a large-scale, multi-tenant SaaS application. But under the hood, the foundation was dangerously naive. The architecture utilized a single, shared Go backend that routed requests to dedicated databases for each tenant.
Because the system was originally built and tested with only a single tenant in mind, a critical architectural flaw was quietly waiting for the day we actually succeeded in getting more users. Once multiple tenants began hitting the production servers concurrently, the system buckled under its own underlying assumptions.
Problem
We started experiencing the absolute worst-case scenario for any SaaS product: data contamination . We received reports of abnormal data suddenly appearing in one tenant's view, while another tenant reported entirely missing records.
It wasn't just a performance bottleneck; it was a fundamental breach of system integrity and user trust. Operational flows halted, and as the Senior Developer, I had to step in immediately and alone to stop the bleeding.
Root Cause
The architecture relied on an early
middleware design that prioritized immediate simplicity over long-term concurrency. To switch tenants, the logic closed the previous database connection, opened a new one, and cached it in a global variable. At the time, it was a perfectly pragmatic decision—it worked seamlessly when the app was only serving a single tenant, and there was no visible reason to over-engineer the system before the scale demanded it.In a highly concurrent Go application, this created massive race conditions. If a background
goroutine or a concurrent request from Tenant A fired off while the global connection had just been overwritten by a new request from Tenant B, Tenant A's data would simply be read from or written to Tenant B's database. It was a ticking time bomb built on global state mutation .Decision and Architecture Shift
The ironic part was that the codebase already possessed the skeleton of a
Repository pattern—it just wasn't being utilized for actual Dependency Injection (DI). Since I had full autonomy to solve the crisis, I completely abandoned the global state approach.Instead of thrashing connections open and closed, I configured the backend to establish persistent connection pools to all tenant databases upon startup, storing them in a globally accessible
map in memory.The crucial structural fix was utilizing
DI properly. The middleware now identifies the tenant, retrieves the correct connection pool from the map, and explicitly injects it down the execution chain: from the Request, to the Controller, to the Service, and finally into the Repository. Every single request and background process was now hard-bound to an isolated database context.Outcome
The cross-tenant data bleeding stopped immediately and permanently. Ironically, by fixing the architectural flaw, backend performance drastically improved. By eliminating the massive overhead of constantly tearing down and rebuilding TCP/DB handshakes for every request, the system became significantly faster.
We traded memory (holding multiple database connection pools open simultaneously) for stability and speed. Given the high traffic volume of each tenant, this was a highly pragmatic and necessary trade-off for a production SaaS.
Reflection
This incident was a humbling reminder of how architectural decisions that make perfect sense in the early days can quietly become liabilities as a system scales. The original design solved the exact problem it was built for, but concurrency has a way of hiding its flaws until the system is under real, multi-tenant load.
A single, basic suite of concurrent unit tests would have exposed the race condition on that global variable long before it ever reached production. It taught me that isolated contexts and strict dependency injection aren't just "clean code" aesthetics—they are the only mechanisms actually protecting your users' data when the system scales.
Questions & Answers
Q.Why didn't you just use context.Context to pass the database connection?
context.Context to pass the database connection?A.
I considered it. In the Go world, putting things in
ctx is the "idiomatic" way to go. But when you’re staring at cross-tenant data contamination in production, "idiomatic" takes a backseat to "explicit." Using ctx felt a bit like a wild card—it's too easy for a developer to pull the wrong key or for the context to get lost in a deep call stack. By using direct Dependency Injection in the Repository—NewRepository(db)—I made the database a hard contract. It’s harder to mess up, and it made writing unit tests with sqlmock much more straightforward.Q.Isn't a global map of connection pools a memory hog?
map of connection pools a memory hog?A.
Yes, we traded
RAM for integrity. Each sql.DB is a pool, and keeping hundreds of them open simultaneously isn't free. However, for our current scale and the high traffic each tenant generates, the overhead of constant TCP handshakes and DB authentication was a much bigger performance killer than the memory footprint. In the early days, "simplicity" meant one global connection. Now, "pragmatism" means persistent pools. If we hit 1,000+ tenants, I’ll likely evolve this into a Lazy Loading LRU cache to evict inactive pools, but that time, stability is the priority.You might also like my other articles