DEV Community

Cover image for How to Build a High-Performance Data Access Layer in Go
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

How to Build a High-Performance Data Access Layer in Go

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Let's talk about building the part of your application that talks to the database. If you've ever written code where every click opens a new database connection, you know the pain. The app gets slow, the database groans under the load, and everything falls apart when too many people show up at once. I've been there.

We need a better approach, a dedicated layer that handles all this communication intelligently. In Go, we can build a data access layer that is fast, reliable, and doesn't waste resources. It's like building a highly efficient switchboard operator for your database calls.

Think of a database connection as a phone line. If every part of your app had to hang up and dial again for every single question, it would be chaos. A connection pool changes that. It keeps a set of open lines ready to go. When your code needs to query the database, it checks out a connection, uses it, and checks it back in. The connection stays alive for the next call.

This saves the costly setup and teardown time for every single operation. Here's a basic look at how we configure a pool using a popular driver.

primaryConfig, _ := pgxpool.ParseConfig(dbURL)
primaryConfig.MaxConns = 50
primaryConfig.MinConns = 5
primaryConfig.MaxConnLifetime = time.Hour
primaryConfig.MaxConnIdleTime = 30 * time.Minute

pool, _ := pgxpool.NewWithConfig(context.Background(), primaryConfig)
Enter fullscreen mode Exit fullscreen mode

We set a maximum so we don't overwhelm the database, and a minimum so some connections are always on standby. We also give connections a lifetime, retiring old ones to prevent subtle network issues from causing problems.

But a pool of raw connections is just the start. One of the biggest performance wins comes from reusing work. When you send a raw SQL string, the database must parse it, understand it, and plan how to execute it, every single time. For queries you run often, this is a lot of wasted effort.

We can prepare statements ahead of time. It's like giving the database a pre-addressed envelope. We say, "Here's the format of my question," and the database prepares an efficient way to answer it. Then, each time we ask, we just slide in the new parameters.

We need to cache these prepared statements. A simple cache keeps them handy in our application's memory.

type StatementCache struct {
    statements map[string]*sql.Stmt
    mu         sync.RWMutex
    maxSize    int
}

func (sc *StatementCache) Get(query string) (*sql.Stmt, error) {
    sc.mu.RLock()
    stmt, exists := sc.statements[query]
    sc.mu.RUnlock()

    if exists {
        return stmt, nil
    }
    return nil, fmt.Errorf("statement not cached")
}

func (sc *StatementCache) Put(query string, stmt *sql.Stmt) {
    sc.mu.Lock()
    defer sc.mu.Unlock()

    if len(sc.statements) >= sc.maxSize {
        // Evict the oldest or least-used statement
    }
    sc.statements[query] = stmt
}
Enter fullscreen mode Exit fullscreen mode

When a query comes in, we check the cache first. If we have a prepared statement, we use it. If not, we prepare it, store it in the cache, and then use it. This can cut the database's CPU usage for repetitive queries nearly in half.

Next, let's think about the data itself. Why ask the database the same question repeatedly if the answer hasn't changed? Caching query results is crucial. I like a two-level approach.

First, a very fast local cache right in the application's memory for super-hot data. Then, a larger, shared cache using something like Redis for data that many parts of the app might need.

Here's a simple in-memory cache with a time-to-live.

type LocalCache struct {
    items   map[string]*CacheItem
    mu      sync.RWMutex
    maxSize int
    ttl     time.Duration
}

type CacheItem struct {
    Value     interface{}
    ExpiresAt time.Time
}

func (lc *LocalCache) Get(key string) interface{} {
    lc.mu.RLock()
    item, exists := lc.items[key]
    lc.mu.RUnlock()

    if !exists {
        return nil
    }
    if time.Now().After(item.ExpiresAt) {
        lc.mu.Lock()
        delete(lc.items, key)
        lc.mu.Unlock()
        return nil
    }
    return item.Value
}
Enter fullscreen mode Exit fullscreen mode

In our main query method, we can check the cache first. We create a cache key from the query and its parameters. If we find the result, we return it instantly. If not, we proceed to the database. After getting the result, we store it in the cache for next time.

This dance between cache and database needs strategy. Some data, like a user's name, can be cached for minutes. Other data, like a live auction bid, can't be cached at all. We configure this per query type.

Now, what about writes? Often, an application needs to perform several related updates—logging an action and updating a user's last login time, for example. Doing these one after another is slow. Each operation is a round trip to the database.

We can batch them. Instead of sending many small packages, we pack multiple operations into one larger shipment. The database can process them together, often within a single transaction for consistency. This is a massive throughput booster.

We set up a channel to collect operations and a processor that sends them in groups.

type QueryBatcher struct {
    operations chan *BatchOperation
    results    chan *BatchResult
    batchSize  int
    timeout    time.Duration
}

func (b *QueryBatcher) Start(ctx context.Context, pool *pgxpool.Pool) {
    go func() {
        var batch []*BatchOperation
        for {
            select {
            case op := <-b.operations:
                batch = append(batch, op)
                if len(batch) >= b.batchSize {
                    b.processBatch(ctx, pool, batch)
                    batch = nil
                }
            case <-time.After(b.timeout):
                if len(batch) > 0 {
                    b.processBatch(ctx, pool, batch)
                    batch = nil
                }
            case <-ctx.Done():
                return
            }
        }
    }()
}
Enter fullscreen mode Exit fullscreen mode

The batcher waits until it has enough operations or until a short time passes, then sends them all at once. This keeps latency low while dramatically increasing the number of operations we can handle per second.

With all these moving parts, we need to know what's happening. Is the cache working? Are connections healthy? We collect metrics on everything.

We track total queries, cache hits and misses, errors, and how long queries take. These numbers tell us if our pool is sized correctly or if our cache strategy needs adjustment.

type DALStats struct {
    QueriesTotal    uint64
    CacheHits       uint64
    CacheMisses     uint64
    QueryErrors     uint64
    QueryDuration   uint64 // nanoseconds
}
Enter fullscreen mode Exit fullscreen mode

We use atomic operations to update these counters safely from multiple goroutines. A separate monitoring goroutine can read these stats and expose them for a dashboard or alerting system.

We also need to monitor the connection pool itself. A simple health check pings the database periodically.

type PoolHealth struct {
    lastCheck time.Time
    healthy   bool
    latency   time.Duration
}

func (ph *PoolHealth) Check(ctx context.Context, pool *pgxpool.Pool) bool {
    ph.mu.Lock()
    defer ph.mu.Unlock()
    if time.Since(ph.lastCheck) < 10*time.Second {
        return ph.healthy // Don't check too often
    }
    start := time.Now()
    err := pool.Ping(ctx)
    ph.latency = time.Since(start)
    ph.lastCheck = time.Now()
    if err != nil {
        ph.healthy = false
        return false
    }
    ph.healthy = true
    return true
}
Enter fullscreen mode Exit fullscreen mode

If a health check fails, we can log an alert, maybe start failing over to a replica, or shed non-critical traffic to keep the core system alive.

Finally, let's wrap this all into a single, cohesive layer. The DataAccessLayer struct becomes the front door for all database interactions.

type DataAccessLayer struct {
    dbPool      *DatabasePool
    redisClient *redis.Client
    queryCache  *QueryCache
    batcher     *QueryBatcher
    stats       DALStats
    config      DALConfig
}

func (dal *DataAccessLayer) Query(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    // 1. Check local, then Redis cache
    // 2. Get connection from pool
    // 3. Use cached prepared statement or create one
    // 4. Execute query
    // 5. Cache the result
    // 6. Update metrics
    // 7. Return rows
}

func (dal *DataAccessLayer) Execute(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
    // Execute a write operation, possibly with retry logic
}
Enter fullscreen mode Exit fullscreen mode

Using it becomes straightforward.

func main() {
    dal, _ := NewDataAccessLayer("postgres://user:pass@localhost/db", "localhost:6379")
    defer dal.Close()

    ctx := context.Background()

    // A read query that benefits from caching and prepared statements
    rows, _ := dal.Query(ctx, "SELECT * FROM users WHERE email = $1", "user@example.com")
    defer rows.Close()

    // A batch of writes
    ops := []*BatchOperation{
        {Query: "INSERT INTO audit_log (event) VALUES ($1)", Args: []interface{}{"login"}},
        {Query: "UPDATE users SET last_seen = NOW() WHERE id = $1", Args: []interface{}{userID}},
    }
    results, _ := dal.BatchExecute(ctx, ops)
}
Enter fullscreen mode Exit fullscreen mode

Building this layer requires upfront effort. You must decide on cache times, pool sizes, and batch windows. But the payoff is immense. Your database load drops significantly. Your application responds faster because results come from memory more often. It handles traffic spikes gracefully because connections are managed efficiently.

You move from a fragile system where the database is a constant bottleneck to a resilient one. The database becomes a quiet, efficient backend, not the source of daily fires. You can sleep better knowing your data layer is built to handle the load.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)