Building Production-Grade Go API: Project Setup and Database Architecture
Setting up a production-ready Go/Gin API server with PostgreSQL, proper project structure, and development workflow. From zero to deployment-ready backend infrastructure.
Building Production-Grade Go API: Project Setup and Database Architecture
After building REST APIs with Node.js and exploring Go fundamentals, I decided to tackle a more ambitious project: creating a production-grade Go API server with proper architecture, database migrations, and development workflow. This isn't just about making it work—it's about building it right from the start.
Project Goals and Philosophy
Unlike my previous Go REST API experiment, this project focuses on:
- Production-ready architecture following Go best practices
- Proper project structure for maintainability and scalability
- Database migration system for version control
- Development workflow with hot reloading and automation
- Cloud-native design ready for deployment
The complete project is available on GitHub: my-go-next-todo
Project Structure: Following Go Standards
The Standard Go Project Layout
my-go-next-todo/
├── cmd/
│ └── server/
│ └── main.go # Application entry point
├── internal/ # Private packages (not importable)
│ ├── config/
│ │ └── config.go # Configuration management
│ ├── database/
│ │ └── database.go # Database connection management
│ └── server/
│ └── server.go # HTTP server setup
├── migrations/ # Database migrations
│ ├── 000001_create_users_table.up.sql
│ ├── 000001_create_users_table.down.sql
│ ├── 000002_create_todos_table.up.sql
│ └── 000002_create_todos_table.down.sql
├── pkg/ # Public libraries (future expansion)
├── .env.example # Environment template
├── air.toml # Hot reload configuration
├── Makefile # Development commands
├── go.mod # Go module definition
└── README.md # Project documentation
Why This Structure Matters
cmd/: Application entry points. Each subdirectory represents a different binary.
internal/: Private packages that cannot be imported by external projects. This enforces encapsulation and prevents internal APIs from being used elsewhere.
pkg/: Public libraries that can be imported by external applications (when needed).
migrations/: Database schema changes tracked in version control.
Development Environment Setup
Essential Tools Installation
# Hot reload for development
go install github.com/cosmtrek/air@latest
# Database migration tool
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Project Initialization
# Initialize Go module
go mod init github.com/jayk0001/my-go-next-todo
# Create directory structure
mkdir -p cmd/server internal/{config,database,server} migrations pkg
Dependencies and Technology Stack
Core Dependencies
// go.mod
module github.com/jayk0001/my-go-next-todo
go 1.21
require (
github.com/gin-gonic/gin v1.9.1 // HTTP framework
github.com/jackc/pgx/v5 v5.4.3 // PostgreSQL driver
github.com/joho/godotenv v1.4.0 // Environment loader
github.com/golang-migrate/migrate/v4 v4.16.2 // Migration tool
)
Technology Choices
Gin Framework: Fast HTTP router with middleware support, similar to Express.js but with Go's performance benefits.
PGX Driver: Pure Go PostgreSQL driver with excellent performance and feature support.
Neon Database: Serverless PostgreSQL for development and production scalability.
golang-migrate: Industry-standard database migration tool with up/down migration support.
Database Design and Architecture
Users Table Schema
-- migrations/000001_create_users_table.up.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add index for email lookups
CREATE INDEX idx_users_email ON users(email);
Todos Table Schema
-- migrations/000002_create_todos_table.up.sql
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
completed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for efficient queries
CREATE INDEX idx_todos_user_id ON todos(user_id);
CREATE INDEX idx_todos_completed ON todos(completed);
CREATE INDEX idx_todos_created_at ON todos(created_at);
Migration Strategy
Each migration consists of two files:
.up.sql: Forward migration (create/modify).down.sql: Reverse migration (rollback changes)
-- migrations/000002_create_todos_table.down.sql
DROP INDEX IF EXISTS idx_todos_created_at;
DROP INDEX IF EXISTS idx_todos_completed;
DROP INDEX IF EXISTS idx_todos_user_id;
DROP TABLE IF EXISTS todos;
Core Implementation
Configuration Management
// internal/config/config.go
package config
import (
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
Port string
JWTSecret string
Environment string
}
func Load() (*Config, error) {
// Load .env file in development
if os.Getenv("ENVIRONMENT") != "production" {
godotenv.Load()
}
return &Config{
DatabaseURL: getEnv("DATABASE_URL", ""),
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", ""),
Environment: getEnv("ENVIRONMENT", "development"),
}, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Database Connection Management
// internal/database/database.go
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
Pool *pgxpool.Pool
}
func New(databaseURL string) (*DB, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse database URL: %w", err)
}
// Configure connection pool
config.MaxConns = 30
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = time.Minute * 30
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
// Test connection
if err := pool.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DB{Pool: pool}, nil
}
func (db *DB) Health(ctx context.Context) error {
return db.Pool.Ping(ctx)
}
func (db *DB) Close() {
db.Pool.Close()
}
HTTP Server Setup
// internal/server/server.go
package server
import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"your-project/internal/database"
)
type Server struct {
router *gin.Engine
db *database.DB
}
func New(db *database.DB) *Server {
// Set Gin mode based on environment
if gin.Mode() == gin.ReleaseMode {
gin.SetMode(gin.ReleaseMode)
}
router := gin.Default()
server := &Server{
router: router,
db: db,
}
server.setupRoutes()
return server
}
func (s *Server) setupRoutes() {
// Health check endpoints (Kubernetes compatible)
s.router.GET("/health", s.healthHandler)
s.router.GET("/health/ready", s.readinessHandler)
s.router.GET("/health/live", s.livenessHandler)
// API routes
api := s.router.Group("/api/v1")
{
api.GET("/ping", s.pingHandler)
}
}
func (s *Server) healthHandler(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
dbStatus := "healthy"
if err := s.db.Health(ctx); err != nil {
dbStatus = "unhealthy"
}
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"timestamp": time.Now().Format(time.RFC3339),
"version": "0.1.0",
"services": gin.H{
"database": dbStatus,
},
})
}
func (s *Server) readinessHandler(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
if err := s.db.Health(ctx); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"status": "not ready",
"error": "database connection failed",
})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ready"})
}
func (s *Server) livenessHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "alive"})
}
func (s *Server) pingHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
}
func (s *Server) Start(port string) error {
return s.router.Run(":" + port)
}
Application Entry Point
// cmd/server/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"your-project/internal/config"
"your-project/internal/database"
"your-project/internal/server"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
// Connect to database
db, err := database.New(cfg.DatabaseURL)
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer db.Close()
// Create server
srv := server.New(db)
// Setup graceful shutdown
httpServer := &http.Server{
Addr: ":" + cfg.Port,
Handler: srv,
}
// Start server in goroutine
go func() {
log.Printf("Server starting on port %s", cfg.Port)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("Server failed to start:", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Server shutting down...")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exited")
}
Development Workflow Automation
Makefile for Common Tasks
# Makefile
.PHONY: help dev build test clean db-up db-down db-reset
# Default target
help:
@echo "Available commands:"
@echo " dev - Start development server with hot reload"
@echo " build - Build production binary"
@echo " test - Run tests"
@echo " db-up - Run database migrations"
@echo " db-down - Rollback database migrations"
@echo " db-reset - Reset database (down then up)"
# Development server with hot reload
dev:
air
# Build production binary
build:
CGO_ENABLED=0 GOOS=linux go build -o bin/server ./cmd/server
# Run tests
test:
go test -v ./...
# Database migrations
db-up:
migrate -path migrations -database "$(DATABASE_URL)" up
db-down:
migrate -path migrations -database "$(DATABASE_URL)" down
db-reset: db-down db-up
# Clean build artifacts
clean:
rm -rf bin/ tmp/
Air Hot Reload Configuration
# air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/server"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = ["cmd", "internal"]
include_ext = ["go", "tpl", "tmpl", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_root = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false
API Endpoints and Testing
Health Check Endpoints
The server provides comprehensive health check endpoints compatible with Kubernetes:
# Overall health status
curl http://localhost:8080/health
{
"status": "healthy",
"timestamp": "2025-10-25T15:24:27Z",
"version": "0.1.0",
"services": {
"database": "healthy"
}
}
# Readiness probe (ready to serve traffic)
curl http://localhost:8080/health/ready
{"status": "ready"}
# Liveness probe (application is running)
curl http://localhost:8080/health/live
{"status": "alive"}
# Basic connectivity test
curl http://localhost:8080/api/v1/ping
{"message": "pong"}
Environment Configuration
Environment Variables Setup
# .env.example
DATABASE_URL=postgresql://username:password@host:5432/database?sslmode=require
JWT_SECRET=your-super-secret-32-characters-minimum-key
PORT=8080
ENVIRONMENT=development
Neon Database Integration
For this project, I used Neon as the PostgreSQL provider:
# Neon connection string format
DATABASE_URL=postgresql://username:password@ep-example.us-east-2.aws.neon.tech/neondb?sslmode=require
Benefits of Neon:
- Serverless: Automatic scaling and hibernation
- Branching: Database branches for different environments
- Built-in SSL: Secure connections by default
Running the Project
Step 1: Environment Setup
# Clone and setup
git clone https://github.com/jayk0001/my-go-next-todo
cd my-go-next-todo
# Copy environment template
cp .env.example .env
# Edit .env with your database credentials
Step 2: Database Migration
# Run migrations
make db-up
# Verify migration status
migrate -path migrations -database "$DATABASE_URL" version
Step 3: Start Development Server
# Start with hot reload
make dev
# Test the server
curl http://localhost:8080/health
Key Learning Points
Go Best Practices Applied
Standard Project Layout: Following the community-accepted structure improves maintainability and makes the project familiar to other Go developers.
Dependency Injection: All dependencies are injected through constructors, making the code testable and modular.
Context Propagation: Every function that might perform I/O accepts a context.Context for cancellation and timeout handling.
Error Handling: Explicit error handling at every level with proper error wrapping using fmt.Errorf.
Database Management Insights
Migration Versioning: Each migration has a version number and both up/down scripts, enabling safe rollbacks.
Connection Pooling: Proper pool configuration prevents connection exhaustion and improves performance.
Index Strategy: Strategic indexing on frequently queried columns improves performance without over-indexing.
Production Readiness Features
Health Checks: Multiple health check endpoints for different monitoring needs (Kubernetes, load balancers).
Graceful Shutdown: Proper signal handling ensures in-flight requests complete before shutdown.
Environment-based Configuration: Twelve-factor app compliance with environment variable configuration.
SSL/TLS Ready: Database connections use SSL by default for security.
Troubleshooting Common Issues
Migration Failures
# Check current migration version
migrate -path migrations -database "$DATABASE_URL" version
# Force to specific version if stuck
migrate -path migrations -database "$DATABASE_URL" force 1
# Then run migrations again
make db-up
Connection Pool Issues
# Check active connections
SELECT count(*) FROM pg_stat_activity WHERE datname = 'your_database';
# Adjust pool settings in database.go if needed
config.MaxConns = 10 // Reduce if hitting connection limits
Hot Reload Not Working
# Ensure air.toml includes correct directories
include_dir = ["cmd", "internal"]
include_ext = ["go"]
Performance Considerations
Database Optimization
- Connection Pooling: Configured for cloud database latency
- Index Strategy: Covering indexes for common query patterns
- Query Timeout: All database operations have timeouts
Server Optimization
- Gin Release Mode: Production builds use optimized Gin mode
- Graceful Shutdown: Prevents connection drops during deployment
- Health Check Caching: Avoid overwhelming database with health checks
Next Steps and Future Enhancements
This foundation sets up the infrastructure for a full-featured TODO application. The next phases will include:
- JWT Authentication System: User registration, login, and token management
- TODO CRUD Operations: Complete REST API for todo management
- Middleware Architecture: Request logging, rate limiting, and validation
- Error Handling Framework: Structured error responses and logging
- Testing Strategy: Unit tests, integration tests, and API testing
- Docker Containerization: Production deployment preparation
Conclusion
Building a production-grade Go API requires more than just making the code work. This project demonstrates:
Proper Architecture: Following Go conventions and best practices from the start prevents technical debt.
Development Workflow: Automation and hot reloading improve developer productivity significantly.
Database Management: Proper migration strategy and connection management are crucial for long-term maintainability.
Production Readiness: Health checks, graceful shutdown, and environment-based configuration prepare the application for real-world deployment.
The complete implementation is available on GitHub, and the next post will cover implementing JWT authentication and user management on top of this foundation.
Key Takeaway: Taking time to set up proper infrastructure pays dividends throughout the project lifecycle. The extra effort in project structure and tooling makes future development much more efficient and maintainable.
References:
- Standard Go Project Layout
- GitHub Repository: my-go-next-todo
- Gin Web Framework Documentation
- PGX PostgreSQL Driver
This post documents the actual development process of building a production-ready Go API server. All code examples are from the working implementation available on GitHub.