Build a Task API with Go, PostgreSQL and Docker
Complete project: REST API in Go with PostgreSQL, Docker, migrations, tests and clean architecture. From zero to deployment.

Another task API. I know. But most tutorials stop at a CRUD against a hardcoded database, with no migrations, no integration tests, no Docker, and all the logic crammed into main.go. When you finish you have something that works but teaches you nothing about how real software is built.
This project is different. We are going to build the same thing (a task API) but making the decisions you would make in a real project: layered architecture, migrations with golang-migrate, a repository backed by PostgreSQL, unit tests with mocks, integration tests with testcontainers, configuration via environment variables, and Docker so anyone can spin up the project with a single command.
The CRUD is the vehicle. The architecture, testing, and deployment decisions are the destination.
If you come from the REST API with Go article where we used in-memory storage, this is the natural evolution: connecting to a real database and making the project production-ready.
What we are going to build
A REST API with these endpoints:
| Method | Path | Description |
|---|---|---|
GET | /api/tasks | List all tasks |
GET | /api/tasks/{id} | Get a task by ID |
POST | /api/tasks | Create a new task |
PUT | /api/tasks/{id} | Update a task |
DELETE | /api/tasks/{id} | Delete a task |
And these pieces:
- Go + Gin as the HTTP framework
- PostgreSQL as the database
- golang-migrate for schema migrations
- pgx as the PostgreSQL driver (not the generic database/sql)
- testcontainers-go for real integration tests
- Docker + docker-compose to bring up the entire environment
We are not going to use ORMs. If you want to understand how database interaction works, you need to write SQL. An ORM hides exactly the parts you need to master.
Project structure
task-api/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── task.go
│ ├── model/
│ │ └── task.go
│ ├── repository/
│ │ ├── task.go
│ │ └── task_test.go
│ ├── service/
│ │ ├── task.go
│ │ └── task_test.go
│ └── apperror/
│ └── errors.go
├── migrations/
│ ├── 000001_create_tasks_table.up.sql
│ └── 000001_create_tasks_table.down.sql
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── go.mod
└── go.sumIf you have read the article on project structure, this will look familiar. cmd/ for entry points, internal/ for module-private code, packages by responsibility. The new addition here is migrations/ for the golang-migrate SQL files.
Initialize with:
go mod init github.com/your-username/task-apiDependencies we will need:
go get github.com/gin-gonic/gin
go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/pgxpool
go get -tags 'postgres' github.com/golang-migrate/migrate/v4
go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgresDatabase: schema and migrations with golang-migrate
Before writing Go, we define the database. The schema is simple but has what matters: correct types, automatic timestamps, and a sensible index.
The migration
-- migrations/000001_create_tasks_table.up.sql
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
done BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_done ON tasks (done);-- migrations/000001_create_tasks_table.down.sql
DROP TABLE IF EXISTS tasks;Decisions that matter:
SERIALinstead ofBIGSERIAL: For a task table, anint4that supports up to 2 billion rows is more than enough. Don’t useBIGSERIALby default; it wastes double the index space unnecessarily.TIMESTAMPTZinstead ofTIMESTAMP: Always with time zone. Without it, every application interprets dates however it likes. In production this causes subtle and painful bugs.DEFAULT ''on description: Avoids having to deal withNULLin strings. An empty string is easier to handle in Go than a nullable*string.- Index on
done: If the main use case is “give me the pending tasks”, this index speeds up the most common query. It is not mandatory for a small project, but it is the kind of decision you should make from the start.
Applying migrations from Go
You could run migrations from the command line using the golang-migrate CLI, but integrating them into the application startup has advantages: the server won’t start if the database is not up to date, and you don’t depend on someone remembering to run a command before deployment.
// internal/config/migrate.go
package config
import (
"fmt"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func RunMigrations(databaseURL string, migrationsPath string) error {
m, err := migrate.New(
fmt.Sprintf("file://%s", migrationsPath),
databaseURL,
)
if err != nil {
return fmt.Errorf("failed to create migrate instance: %w", err)
}
defer m.Close()
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to run migrations: %w", err)
}
return nil
}migrate.ErrNoChange is not a real error: it means the migrations are already applied. If you treat it as an error, the server will fail on every restart after the first.
If you want to go deeper into the connection and handling of PostgreSQL with Go, I have a dedicated article where we cover pgx in detail.
Models and the repository layer
The domain model
// internal/model/task.go
package model
import "time"
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateTaskRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
}
type UpdateTaskRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Done *bool `json:"done"`
}The pointers in UpdateTaskRequest are key. Without them you cannot distinguish between “the user didn’t send this field” and “the user sent an empty value”. It’s the difference between a real PATCH and a PUT in disguise. If you come from languages with Optional or null, pointers are the Go equivalent.
The binding:"required" tag is from Gin. It validates that the field exists in the JSON before it reaches the service.
The repository interface
// internal/repository/task.go
package repository
import (
"context"
"github.com/your-username/task-api/internal/model"
)
type TaskRepository interface {
GetAll(ctx context.Context) ([]model.Task, error)
GetByID(ctx context.Context, id int) (*model.Task, error)
Create(ctx context.Context, task *model.Task) error
Update(ctx context.Context, task *model.Task) error
Delete(ctx context.Context, id int) error
}Critical difference compared to the REST API with Go article where we used an in-memory repository: here all methods receive context.Context. When you talk to a database, you need context for timeouts and cancellations. If an HTTP request is cancelled, the context propagates all the way to the query and PostgreSQL stops working on it. Without context, the query keeps running even though nobody is waiting for the result.
The PostgreSQL implementation
// internal/repository/postgres_task.go
package repository
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/your-username/task-api/internal/model"
)
type PostgresTaskRepository struct {
pool *pgxpool.Pool
}
func NewPostgresTaskRepository(pool *pgxpool.Pool) *PostgresTaskRepository {
return &PostgresTaskRepository{pool: pool}
}
func (r *PostgresTaskRepository) GetAll(ctx context.Context) ([]model.Task, error) {
rows, err := r.pool.Query(ctx,
"SELECT id, title, description, done, created_at, updated_at FROM tasks ORDER BY created_at DESC",
)
if err != nil {
return nil, fmt.Errorf("query tasks: %w", err)
}
defer rows.Close()
var tasks []model.Task
for rows.Next() {
var t model.Task
if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Done, &t.CreatedAt, &t.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
tasks = append(tasks, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate tasks: %w", err)
}
return tasks, nil
}
func (r *PostgresTaskRepository) GetByID(ctx context.Context, id int) (*model.Task, error) {
var t model.Task
err := r.pool.QueryRow(ctx,
"SELECT id, title, description, done, created_at, updated_at FROM tasks WHERE id = $1",
id,
).Scan(&t.ID, &t.Title, &t.Description, &t.Done, &t.CreatedAt, &t.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("task with id %d not found", id)
}
return nil, fmt.Errorf("query task: %w", err)
}
return &t, nil
}
func (r *PostgresTaskRepository) Create(ctx context.Context, task *model.Task) error {
err := r.pool.QueryRow(ctx,
`INSERT INTO tasks (title, description, done, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
task.Title, task.Description, task.Done, time.Now(), time.Now(),
).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt)
if err != nil {
return fmt.Errorf("insert task: %w", err)
}
return nil
}
func (r *PostgresTaskRepository) Update(ctx context.Context, task *model.Task) error {
task.UpdatedAt = time.Now()
result, err := r.pool.Exec(ctx,
`UPDATE tasks
SET title = $1, description = $2, done = $3, updated_at = $4
WHERE id = $5`,
task.Title, task.Description, task.Done, task.UpdatedAt, task.ID,
)
if err != nil {
return fmt.Errorf("update task: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("task with id %d not found", task.ID)
}
return nil
}
func (r *PostgresTaskRepository) Delete(ctx context.Context, id int) error {
result, err := r.pool.Exec(ctx,
"DELETE FROM tasks WHERE id = $1",
id,
)
if err != nil {
return fmt.Errorf("delete task: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("task with id %d not found", id)
}
return nil
}Things to notice:
pgxpool.Pool, not a single connection. The pool manages multiple connections, reuses them, and handles automatic reconnections. In an HTTP server with concurrent requests, a single connection would be a bottleneck.RETURNINGin the INSERT. Instead of doing an INSERT and then a SELECT to get the generated ID and timestamps, PostgreSQL returns them in the same query. One round-trip to the server instead of two.RowsAffected()in UPDATE and DELETE. If no row was affected, the resource doesn’t exist. This prevents a DELETE on a non-existent ID from returning 200 OK as if it had deleted something.- Parameters
$1, $2, $3instead of string concatenation. This prevents SQL injection. Never, under any circumstances, build queries withfmt.Sprintf.
Service layer: business logic
The service is the orchestration layer. It receives requests from the handler, validates them, calls the repository, and transforms storage errors into domain errors.
// internal/service/task.go
package service
import (
"context"
"strings"
"github.com/your-username/task-api/internal/apperror"
"github.com/your-username/task-api/internal/model"
"github.com/your-username/task-api/internal/repository"
)
type TaskService struct {
repo repository.TaskRepository
}
func NewTaskService(repo repository.TaskRepository) *TaskService {
return &TaskService{repo: repo}
}
func (s *TaskService) GetAll(ctx context.Context) ([]model.Task, error) {
tasks, err := s.repo.GetAll(ctx)
if err != nil {
return nil, apperror.NewInternal("failed to retrieve tasks", err)
}
return tasks, nil
}
func (s *TaskService) GetByID(ctx context.Context, id int) (*model.Task, error) {
task, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, apperror.NewNotFound("task not found")
}
return task, nil
}
func (s *TaskService) Create(ctx context.Context, req model.CreateTaskRequest) (*model.Task, error) {
title := strings.TrimSpace(req.Title)
if title == "" {
return nil, apperror.NewValidation("title is required")
}
if len(title) > 255 {
return nil, apperror.NewValidation("title must be 255 characters or less")
}
task := &model.Task{
Title: title,
Description: strings.TrimSpace(req.Description),
Done: false,
}
if err := s.repo.Create(ctx, task); err != nil {
return nil, apperror.NewInternal("failed to create task", err)
}
return task, nil
}
func (s *TaskService) Update(ctx context.Context, id int, req model.UpdateTaskRequest) (*model.Task, error) {
task, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, apperror.NewNotFound("task not found")
}
if req.Title != nil {
title := strings.TrimSpace(*req.Title)
if title == "" {
return nil, apperror.NewValidation("title cannot be empty")
}
if len(title) > 255 {
return nil, apperror.NewValidation("title must be 255 characters or less")
}
task.Title = title
}
if req.Description != nil {
task.Description = strings.TrimSpace(*req.Description)
}
if req.Done != nil {
task.Done = *req.Done
}
if err := s.repo.Update(ctx, task); err != nil {
return nil, apperror.NewInternal("failed to update task", err)
}
return task, nil
}
func (s *TaskService) Delete(ctx context.Context, id int) error {
_, err := s.repo.GetByID(ctx, id)
if err != nil {
return apperror.NewNotFound("task not found")
}
if err := s.repo.Delete(ctx, id); err != nil {
return apperror.NewInternal("failed to delete task", err)
}
return nil
}The service receives the TaskRepository interface, not the PostgreSQL implementation. This is what allows unit tests to work with a mock without needing a database. It’s not over-engineering: it’s basic testability.
Validations live here, not in the handler. If tomorrow you add a CLI that creates tasks, or a Kafka consumer, the validations still apply because they are in the service, not tied to HTTP.
Repository errors are transformed into AppError. The handler never sees a fmt.Errorf("task with id %d not found") from the repo. It sees an apperror.NotFound, and knows exactly which HTTP status code to return.
HTTP handlers with Gin
Handlers are the boundary between HTTP and your domain. Their job is simple: parse the request, call the service, return the response. Any business logic here is a signal that something is in the wrong place.
// internal/handler/task.go
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/your-username/task-api/internal/apperror"
"github.com/your-username/task-api/internal/model"
"github.com/your-username/task-api/internal/service"
)
type TaskHandler struct {
service *service.TaskService
}
func NewTaskHandler(service *service.TaskService) *TaskHandler {
return &TaskHandler{service: service}
}
func (h *TaskHandler) handleError(c *gin.Context, err error) {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
status := http.StatusInternalServerError
switch appErr.Type {
case apperror.NotFound:
status = http.StatusNotFound
case apperror.Validation:
status = http.StatusBadRequest
case apperror.Conflict:
status = http.StatusConflict
}
c.JSON(status, gin.H{"error": appErr.Message})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
func (h *TaskHandler) GetAll(c *gin.Context) {
tasks, err := h.service.GetAll(c.Request.Context())
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, tasks)
}
func (h *TaskHandler) GetByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
task, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, task)
}
func (h *TaskHandler) Create(c *gin.Context) {
var req model.CreateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
task, err := h.service.Create(c.Request.Context(), req)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, task)
}
func (h *TaskHandler) Update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req model.UpdateTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
task, err := h.service.Update(c.Request.Context(), id, req)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, task)
}
func (h *TaskHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func RegisterRoutes(r *gin.Engine, h *TaskHandler) {
api := r.Group("/api")
{
api.GET("/tasks", h.GetAll)
api.GET("/tasks/:id", h.GetByID)
api.POST("/tasks", h.Create)
api.PUT("/tasks/:id", h.Update)
api.DELETE("/tasks/:id", h.Delete)
}
}Notice c.Request.Context(). Gin has its own context, but the service and repository expect a standard context.Context. Passing the HTTP request’s context means that if the client closes the connection, the cancellation propagates all the way to the PostgreSQL query. Without this, queries keep running even though nobody is waiting for the response.
ShouldBindJSON is from Gin and combines json.Decode with validation of the binding tags. If the title field is not in the POST body, it returns an error before the service ever sees it.
handleError centralizes the translation of AppError to HTTP status code. A single place to change the logic if you need to add a new error type.
If you want to better understand how Gin and its middlewares work, I have an article on the Gin framework in Go.
Configuration with environment variables
Nothing hardcoded. Port, database URL, timeouts: everything comes from the environment. This makes the same binary work in development, staging, and production without recompiling.
// internal/config/config.go
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Port int
DatabaseURL string
MigrationsPath string
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
}
func Load() (*Config, error) {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
port, err := getEnvInt("PORT", 8080)
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
readTimeout, err := getEnvDuration("READ_TIMEOUT", 5*time.Second)
if err != nil {
return nil, fmt.Errorf("invalid READ_TIMEOUT: %w", err)
}
writeTimeout, err := getEnvDuration("WRITE_TIMEOUT", 10*time.Second)
if err != nil {
return nil, fmt.Errorf("invalid WRITE_TIMEOUT: %w", err)
}
shutdownTimeout, err := getEnvDuration("SHUTDOWN_TIMEOUT", 15*time.Second)
if err != nil {
return nil, fmt.Errorf("invalid SHUTDOWN_TIMEOUT: %w", err)
}
migrationsPath := os.Getenv("MIGRATIONS_PATH")
if migrationsPath == "" {
migrationsPath = "migrations"
}
return &Config{
Port: port,
DatabaseURL: dbURL,
MigrationsPath: migrationsPath,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ShutdownTimeout: shutdownTimeout,
}, nil
}
func getEnvInt(key string, defaultVal int) (int, error) {
val, exists := os.LookupEnv(key)
if !exists {
return defaultVal, nil
}
return strconv.Atoi(val)
}
func getEnvDuration(key string, defaultVal time.Duration) (time.Duration, error) {
val, exists := os.LookupEnv(key)
if !exists {
return defaultVal, nil
}
return time.ParseDuration(val)
}DATABASE_URL has no default value. If it is not defined, the application won’t start. It’s better to fail fast with a clear message than to start and later fail with a cryptic “connection refused”.
The .env.example file serves as documentation:
# .env.example
PORT=8080
DATABASE_URL=postgres://taskuser:taskpass@localhost:5432/taskdb?sslmode=disable
MIGRATIONS_PATH=migrations
READ_TIMEOUT=5s
WRITE_TIMEOUT=10s
SHUTDOWN_TIMEOUT=15sDocker: Dockerfile and docker-compose
The Dockerfile
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
# Run stage
FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /server .
COPY migrations/ ./migrations/
EXPOSE 8080
CMD ["./server"]Multi-stage build. The final image contains no Go compiler, no source code, no build dependencies. Just the binary and the migrations. Result: an image of about 15-20 MB instead of the 800+ MB of the Go image.
CGO_ENABLED=0 is important. Without it, the binary might depend on C libraries from the system that are not in Alpine. With CGO disabled, the binary is completely static.
We copy migrations/ because the application runs them at startup. If migrations are applied by an external process, you can remove that line.
If you want to go deeper into the decisions behind dockerizing a Go API, I have a dedicated article.
docker-compose
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
POSTGRES_DB: taskdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskuser -d taskdb"]
interval: 5s
timeout: 5s
retries: 5
api:
build: .
ports:
- "8080:8080"
environment:
PORT: 8080
DATABASE_URL: postgres://taskuser:taskpass@db:5432/taskdb?sslmode=disable
MIGRATIONS_PATH: migrations
depends_on:
db:
condition: service_healthy
volumes:
pgdata:The healthcheck on PostgreSQL is crucial. Without it, depends_on only guarantees that the container has started, not that PostgreSQL is accepting connections. The API would try to connect before the database is ready and fail. With condition: service_healthy, Docker waits for pg_isready to return success.
The pgdata volume persists data between container restarts. Without it, every docker-compose down && docker-compose up starts with an empty database. Useful in development if you want that, but inconvenient if you don’t.
The entry point: main.go
// cmd/server/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/your-username/task-api/internal/config"
"github.com/your-username/task-api/internal/handler"
"github.com/your-username/task-api/internal/repository"
"github.com/your-username/task-api/internal/service"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Database connection pool
ctx := context.Background()
pool, err := pgxpool.New(ctx, cfg.DatabaseURL)
if err != nil {
log.Fatalf("failed to create connection pool: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
log.Fatalf("failed to ping database: %v", err)
}
log.Println("connected to database")
// Run migrations
if err := config.RunMigrations(cfg.DatabaseURL, cfg.MigrationsPath); err != nil {
log.Fatalf("failed to run migrations: %v", err)
}
log.Println("migrations applied")
// Wire dependencies
taskRepo := repository.NewPostgresTaskRepository(pool)
taskService := service.NewTaskService(taskRepo)
taskHandler := handler.NewTaskHandler(taskService)
// Setup router
r := gin.Default()
handler.RegisterRoutes(r, taskHandler)
// HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
}
// Graceful shutdown
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
log.Printf("server started on port %d", cfg.Port)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("server forced to shutdown: %v", err)
}
log.Println("server stopped")
}The flow is linear: load configuration, connect to the database, run migrations, wire dependencies, start the server, wait for a shutdown signal.
Graceful shutdown matters. When Kubernetes or Docker sends a SIGTERM, the server stops accepting new connections but finishes the ones in progress. Without this, in-flight requests are cut in the middle of a transaction.
Dependency injection is manual: repo -> service -> handler. You don’t need a DI framework for this. Four lines of code, and each component receives exactly what it needs.
Testing: unit and integration
This is where the layer separation pays dividends. Unit tests for the service don’t need a database. Integration tests for the repository use a real PostgreSQL inside a container.
Unit tests for the service
To test the service without a database, we need a mock repository. The TaskRepository interface makes that possible.
// internal/service/task_test.go
package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/your-username/task-api/internal/model"
)
// Mock repository
type mockTaskRepo struct {
tasks map[int]*model.Task
nextID int
}
func newMockTaskRepo() *mockTaskRepo {
return &mockTaskRepo{
tasks: make(map[int]*model.Task),
nextID: 1,
}
}
func (m *mockTaskRepo) GetAll(ctx context.Context) ([]model.Task, error) {
var tasks []model.Task
for _, t := range m.tasks {
tasks = append(tasks, *t)
}
return tasks, nil
}
func (m *mockTaskRepo) GetByID(ctx context.Context, id int) (*model.Task, error) {
t, exists := m.tasks[id]
if !exists {
return nil, fmt.Errorf("not found")
}
return t, nil
}
func (m *mockTaskRepo) Create(ctx context.Context, task *model.Task) error {
task.ID = m.nextID
task.CreatedAt = time.Now()
task.UpdatedAt = time.Now()
m.tasks[task.ID] = task
m.nextID++
return nil
}
func (m *mockTaskRepo) Update(ctx context.Context, task *model.Task) error {
if _, exists := m.tasks[task.ID]; !exists {
return fmt.Errorf("not found")
}
task.UpdatedAt = time.Now()
m.tasks[task.ID] = task
return nil
}
func (m *mockTaskRepo) Delete(ctx context.Context, id int) error {
if _, exists := m.tasks[id]; !exists {
return fmt.Errorf("not found")
}
delete(m.tasks, id)
return nil
}
func TestCreateTask(t *testing.T) {
repo := newMockTaskRepo()
svc := NewTaskService(repo)
ctx := context.Background()
t.Run("creates task successfully", func(t *testing.T) {
req := model.CreateTaskRequest{
Title: "Buy groceries",
Description: "Milk, eggs, bread",
}
task, err := svc.Create(ctx, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.Title != "Buy groceries" {
t.Errorf("expected title 'Buy groceries', got '%s'", task.Title)
}
if task.Done {
t.Error("new task should not be done")
}
if task.ID == 0 {
t.Error("expected non-zero ID")
}
})
t.Run("rejects empty title", func(t *testing.T) {
req := model.CreateTaskRequest{
Title: " ",
}
_, err := svc.Create(ctx, req)
if err == nil {
t.Fatal("expected error for empty title")
}
})
t.Run("rejects title over 255 characters", func(t *testing.T) {
longTitle := ""
for i := 0; i < 256; i++ {
longTitle += "a"
}
req := model.CreateTaskRequest{
Title: longTitle,
}
_, err := svc.Create(ctx, req)
if err == nil {
t.Fatal("expected error for long title")
}
})
}
func TestUpdateTask(t *testing.T) {
repo := newMockTaskRepo()
svc := NewTaskService(repo)
ctx := context.Background()
// Create a task first
created, _ := svc.Create(ctx, model.CreateTaskRequest{
Title: "Original title",
})
t.Run("updates only provided fields", func(t *testing.T) {
newTitle := "Updated title"
req := model.UpdateTaskRequest{
Title: &newTitle,
}
updated, err := svc.Update(ctx, created.ID, req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Title != "Updated title" {
t.Errorf("expected title 'Updated title', got '%s'", updated.Title)
}
if updated.Done {
t.Error("done should not have changed")
}
})
t.Run("returns not found for non-existent task", func(t *testing.T) {
newTitle := "whatever"
req := model.UpdateTaskRequest{Title: &newTitle}
_, err := svc.Update(ctx, 9999, req)
if err == nil {
t.Fatal("expected not found error")
}
})
}
func TestDeleteTask(t *testing.T) {
repo := newMockTaskRepo()
svc := NewTaskService(repo)
ctx := context.Background()
created, _ := svc.Create(ctx, model.CreateTaskRequest{
Title: "To be deleted",
})
t.Run("deletes existing task", func(t *testing.T) {
err := svc.Delete(ctx, created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = svc.GetByID(ctx, created.ID)
if err == nil {
t.Fatal("expected not found after delete")
}
})
t.Run("returns not found for non-existent task", func(t *testing.T) {
err := svc.Delete(ctx, 9999)
if err == nil {
t.Fatal("expected not found error")
}
})
}The mock implements the TaskRepository interface with a simple in-memory map. We don’t use mockgen or testify/mock: for an interface with five methods, a manual mock is clearer and doesn’t add dependencies.
The tests are straightforward: they verify that the service validates correctly, creates tasks with the expected fields, updates only the fields that were sent, and returns errors for non-existent resources. If you want to go deeper into testing patterns in Go, I have a complete article.
Run them with:
go test ./internal/service/ -vIntegration tests with testcontainers
Unit tests validate business logic. Integration tests validate that the SQL works against a real PostgreSQL. This is where testcontainers shines: it spins up a PostgreSQL container just for the tests and destroys it when done.
// internal/repository/task_test.go
package repository
import (
"context"
"fmt"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
"github.com/your-username/task-api/internal/config"
"github.com/your-username/task-api/internal/model"
)
func setupTestDB(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
ctx := context.Background()
pgContainer, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get connection string: %v", err)
}
// Run migrations
if err := config.RunMigrations(connStr, "../../migrations"); err != nil {
t.Fatalf("failed to run migrations: %v", err)
}
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
t.Fatalf("failed to create pool: %v", err)
}
cleanup := func() {
pool.Close()
if err := pgContainer.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %v", err)
}
}
return pool, cleanup
}
func TestPostgresTaskRepository_CRUD(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
pool, cleanup := setupTestDB(t)
defer cleanup()
repo := NewPostgresTaskRepository(pool)
ctx := context.Background()
// Create
task := &model.Task{
Title: "Integration test task",
Description: "Testing against real PostgreSQL",
Done: false,
}
err := repo.Create(ctx, task)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if task.ID == 0 {
t.Fatal("expected non-zero ID after create")
}
if task.CreatedAt.IsZero() {
t.Fatal("expected non-zero created_at")
}
// GetByID
fetched, err := repo.GetByID(ctx, task.ID)
if err != nil {
t.Fatalf("GetByID failed: %v", err)
}
if fetched.Title != "Integration test task" {
t.Errorf("expected title 'Integration test task', got '%s'", fetched.Title)
}
// Update
fetched.Title = "Updated integration task"
fetched.Done = true
err = repo.Update(ctx, fetched)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
updated, _ := repo.GetByID(ctx, fetched.ID)
if updated.Title != "Updated integration task" {
t.Errorf("expected updated title, got '%s'", updated.Title)
}
if !updated.Done {
t.Error("expected task to be done")
}
// GetAll
tasks, err := repo.GetAll(ctx)
if err != nil {
t.Fatalf("GetAll failed: %v", err)
}
if len(tasks) != 1 {
t.Errorf("expected 1 task, got %d", len(tasks))
}
// Delete
err = repo.Delete(ctx, task.ID)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = repo.GetByID(ctx, task.ID)
if err == nil {
t.Fatal("expected error after delete")
}
}
func TestPostgresTaskRepository_NotFound(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
pool, cleanup := setupTestDB(t)
defer cleanup()
repo := NewPostgresTaskRepository(pool)
ctx := context.Background()
_, err := repo.GetByID(ctx, 99999)
if err == nil {
t.Fatal("expected error for non-existent task")
}
err = repo.Delete(ctx, 99999)
if err == nil {
t.Fatal("expected error deleting non-existent task")
}
}Key points:
testing.Short()allows skipping integration tests withgo test -short. Unit tests run in milliseconds; integration tests need to spin up a Docker container, which takes a few seconds. In CI you’ll want to run both, but locally you sometimes only want the fast ones.setupTestDBspins up a real PostgreSQL and runs the migrations. Each test has a clean database. No shared state between tests.WithOccurrence(2)in the wait strategy is because PostgreSQL logs “ready to accept connections” twice: once at startup and once after initialization. WithoutWithOccurrence(2), the test might try to connect before PostgreSQL is truly ready.- The cleanup closes the pool and destroys the container. Testcontainers handles cleanup, but being explicit doesn’t hurt.
Run the integration tests:
go test ./internal/repository/ -vFor unit tests only:
go test ./internal/service/ -v -shortBringing it all together
Local development
Option 1: Only the database in Docker, the API running locally.
# docker-compose.dev.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: taskuser
POSTGRES_PASSWORD: taskpass
POSTGRES_DB: taskdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:docker-compose -f docker-compose.dev.yml up -d
DATABASE_URL="postgres://taskuser:taskpass@localhost:5432/taskdb?sslmode=disable" go run ./cmd/serverOption 2: Everything in Docker.
docker-compose up --buildVerify it works
Create a task:
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Go", "description": "Build a real project"}'List tasks:
curl http://localhost:8080/api/tasksUpdate:
curl -X PUT http://localhost:8080/api/tasks/1 \
-H "Content-Type: application/json" \
-d '{"done": true}'Delete:
curl -X DELETE http://localhost:8080/api/tasks/1If everything is right, you get the correct HTTP codes: 201 on create, 200 on list and update, 204 on delete, 404 if the ID doesn’t exist, 400 if the body is invalid.
Run all tests
# Unit tests only
go test ./... -short -v
# Everything, including integration (requires Docker)
go test ./... -vA decision template, not just a task API
This is not just a task API. It is a decision template that applies to any Go API with PostgreSQL. The layered separation allows you to test each piece in isolation: the service knows nothing about HTTP, the repository knows nothing about business logic. Versioned migrations with golang-migrate travel with the code and are integrated into startup. Unit tests with manual mocks need no infrastructure, while integration tests with testcontainers validate the SQL against real PostgreSQL. Docker multi-stage builds produce small images, docker-compose with healthchecks guarantees startup order, environment-based configuration decouples the binary from the deployment, and graceful shutdown doesn’t cut in-flight requests.
What’s missing are natural extensions: pagination (GET /api/tasks?page=1&limit=20) to avoid returning thousands of records at once, structured logging with slog or zerolog for JSON with levels and contextual fields, authentication middleware with JWT or API keys leveraging Gin’s middleware system, CI/CD with GitHub Actions to run tests on every push, and observability with Prometheus and OpenTelemetry to go from an API that works to one you can operate.
The complete code for this project is meant to be copied, modified, and used as a base. Change Task for your domain, add the layers you need, and you have a solid starting point for any Go API.


