From Shared Pools to Resource Silos
In a standard Go application,
*sql.DB is not a single connection; it is a thread-safe connection pool manager. Most applications initialize one global pool and share it across all goroutines. While efficient, this assumes a single target database.When a system must handle multiple tenants with dedicated databases, the architecture must evolve from a single manager to an "Array of Managers." In this model, every tenant has its own isolated
*sql.DB instance, creating a physical silo where internal mutexes and idle connection lists are completely separated.The Registry Pattern: How it Works
The core of this architecture is the shift from "Mutation" (changing a global state) to "Selection " (resolving a resource from a registry). This is managed via a thread-safe lookup table.
go
type TenantRegistry struct {
pools map[string]*sql.DB
mu sync.RWMutex
}
func (r *TenantRegistry) GetPool(id string) (*sql.DB, error) {
r.mu.RLock()
pool, exists := r.pools[id]
r.mu.RUnlock()
if exists { return pool, nil }
r.mu.Lock()
defer r.mu.Unlock()
// Double-check to prevent race during init
if p, ok := r.pools[id]; ok { return p, nil }
newPool, _ := sql.Open("postgres", getDSN(id))
r.pools[id] = newPool
return newPool, nil
}Enforcement via Dependency Injection
The registry provides safety, but Dependency Injection provides enforcement. By binding the repository to a specific
*sql.DB instance at instantiation, you create a compiler-enforced silo.The service layer or middleware resolves the pool and injects it into the repository. Once injected, the repository is physically incapable of querying the wrong database because it has no access to any other state.
go
type OrderRepository struct {
db *sql.DB // Scoped to a specific tenant
}
func (repo *OrderRepository) Fetch() {
// Compiler-enforced: no global state is accessed
repo.db.Query("SELECT * FROM orders")
}The "Silo Tax": Pragmatic Trade-offs
Maintaining multiple simultaneous pools involves trading Resident Set Size (RSS) memory for reliability and speed. It is a classic engineering calculation.
Handshake Elimination: By keeping pools "warm," you eliminate the 20ms-100ms TCP/TLS handshake latency. Queries start instantly.Granular Governance: You can setMaxOpenConns(50)for a high-traffic tenant while restricting a trial tenant toMaxOpenConns(2).The Multiplier Effect: tenants idle connections can lead to socket exhaustion. TuningSetMaxIdleConns(1)andSetConnMaxLifetime()is mandatory.
Scaling for Exponential Growth
A single registry works for hundreds of tenants, but thousands require Horizontal Sharding . In this scenario, you group tenants into clusters and deploy sharded backend instances.
Each shard only manages the "warm" pools for its specific subset of tenants. This maintains the explicit pooling model while keeping the memory footprint of individual instances manageable.
The Bottom Line
In software engineering, explicit is always safer than implicit. Shifting to siloed connection pooling allows you to align your architecture with Go’s concurrency model. You trade a higher memory footprint for a system that is fundamentally honest, faster, and immune to the race conditions that plague global state mutation.