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.

Cover for Build a Task API with Go, PostgreSQL and Docker

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:

MethodPathDescription
GET/api/tasksList all tasks
GET/api/tasks/{id}Get a task by ID
POST/api/tasksCreate 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.sum

If 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-api

Dependencies 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/postgres

Database: 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:

  • SERIAL instead of BIGSERIAL: For a task table, an int4 that supports up to 2 billion rows is more than enough. Don’t use BIGSERIAL by default; it wastes double the index space unnecessarily.
  • TIMESTAMPTZ instead of TIMESTAMP: 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 with NULL in 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.
  • RETURNING in 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, $3 instead of string concatenation. This prevents SQL injection. Never, under any circumstances, build queries with fmt.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=15s

Docker: 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/ -v

Integration 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 with go 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.
  • setupTestDB spins 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. Without WithOccurrence(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/ -v

For unit tests only:

go test ./internal/service/ -v -short

Bringing 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/server

Option 2: Everything in Docker.

docker-compose up --build

Verify 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/tasks

Update:

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/1

If 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 ./... -v

A 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.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved