Building Production-Ready Microservices in Go with GoFr: Zero-Config Observability, SQLite & Migrations
When building microservices in Go, developers often spend substantial time assembling boilerplate: wiring up OpenTelemetry tracers, configuring Prometheus metrics exporters, setting up structured JSON loggers with trace correlation, managing database connection pools, and building custom health check endpoints.
GoFr is an opinionated, developer-first Go microservices framework (listed on the CNCF Landscape) designed to eliminate this repetitive plumbing. With GoFr, out-of-the-box observability, configuration management, health probes, and datasource lifecycle handling work seamlessly from a single unified context.
In this tutorial, we will build a production-grade Task Management REST Microservice from scratch using GoFr, featuring:
- Datasource integration with SQLite
- Automated versioned schema migrations
-
Clean REST CRUD endpoints (
POST,GET,PUT,DELETE) - Custom Prometheus business metrics
- Zero-config health checks and OpenTelemetry tracing
- End-to-end integration tests
Source Code: The complete, tested source code for this project is available on GitHub: manchitt/gofr-task-api.
1. Prerequisites & Project Setup
Ensure you have Go 1.26 or higher installed.
Create a new directory and initialize your Go module:
mkdir gofr-task-api && cd gofr-task-api
go mod init gofr-task-api
go get gofr.dev@latest
2. Configuration Management
GoFr follows 12-factor application principles, loading configuration automatically from environment variables or a configs/.env file.
Create configs/.env:
APP_NAME=task-api
HTTP_PORT=9000
METRICS_PORT=2121
DB_DIALECT=sqlite
DB_NAME=tasks.db
Notice what is happening here:
-
DB_DIALECT=sqliteinforms GoFr's SQL datasource manager to initialize an embedded SQLite driver. -
HTTP_PORT=9000sets the REST API server port. -
METRICS_PORT=2121automatically exposes Prometheus metrics on a dedicated port.
3. Versioned Database Migrations
GoFr features a built-in migration system that tracks applied versions directly inside the datastore.
Create a migrations/ directory with two files:
migrations/1730300000_create_tasks_table.go
package migrations
import (
"gofr.dev/pkg/gofr/migration"
)
const createTasksTable = `CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
func createTasks() migration.Migrate {
return migration.Migrate{
UP: func(d migration.Datasource) error {
_, err := d.SQL.Exec(createTasksTable)
return err
},
}
}
migrations/all.go
package migrations
import (
"gofr.dev/pkg/gofr/migration"
)
// All returns all migrations registered in chronological order.
func All() map[int64]migration.Migrate {
return map[int64]migration.Migrate{
1730300000: createTasks(),
}
}
When the application boots, GoFr detects app.Migrate(migrations.All()), inspects the current database version table, and executes pending migrations before starting the HTTP listeners.
4. Building the Application (main.go)
Now let's build the core service in main.go:
package main
import (
"database/sql"
"errors"
"fmt"
"gofr-task-api/migrations"
"gofr.dev/pkg/gofr"
gofrHTTP "gofr.dev/pkg/gofr/http"
)
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
CreatedAt string `json:"created_at,omitempty"`
}
func main() {
app := gofr.New()
// Register migrations
app.Migrate(migrations.All())
// Register Task REST endpoints
app.POST("/tasks", createTask)
app.GET("/tasks", listTasks)
app.GET("/tasks/{id}", getTask)
app.PUT("/tasks/{id}", updateTask)
app.DELETE("/tasks/{id}", deleteTask)
app.Run()
}
// createTask handles POST /tasks
func createTask(c *gofr.Context) (any, error) {
var task Task
if err := c.Bind(&task); err != nil {
return nil, gofrHTTP.ErrorInvalidParam{Params: []string{"body"}}
}
if task.Title == "" {
return nil, gofrHTTP.ErrorMissingParam{Params: []string{"title"}}
}
if task.Status == "" {
task.Status = "pending"
}
if task.Priority == "" {
task.Priority = "medium"
}
query := `INSERT INTO tasks (title, description, status, priority) VALUES (?, ?, ?, ?)`
res, err := c.SQL.ExecContext(c, query, task.Title, task.Description, task.Status, task.Priority)
if err != nil {
return nil, fmt.Errorf("failed to insert task: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return nil, fmt.Errorf("failed to retrieve last insert id: %w", err)
}
task.ID = id
// Increment custom Prometheus metric
c.Metrics().IncrementCounter(c, "tasks_created_total")
return task, nil
}
// listTasks handles GET /tasks with optional ?status= filter
func listTasks(c *gofr.Context) (any, error) {
statusFilter := c.Param("status")
var (
rows *sql.Rows
err error
)
if statusFilter != "" {
query := `SELECT id, title, description, status, priority, created_at FROM tasks WHERE status = ? ORDER BY id DESC`
rows, err = c.SQL.QueryContext(c, query, statusFilter)
} else {
query := `SELECT id, title, description, status, priority, created_at FROM tasks ORDER BY id DESC`
rows, err = c.SQL.QueryContext(c, query)
}
if err != nil {
return nil, fmt.Errorf("failed to query tasks: %w", err)
}
defer rows.Close()
tasks := make([]Task, 0)
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.Priority, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan task: %w", err)
}
tasks = append(tasks, t)
}
return tasks, nil
}
// getTask handles GET /tasks/{id}
func getTask(c *gofr.Context) (any, error) {
id := c.PathParam("id")
if id == "" {
return nil, gofrHTTP.ErrorMissingParam{Params: []string{"id"}}
}
var t Task
query := `SELECT id, title, description, status, priority, created_at FROM tasks WHERE id = ?`
err := c.SQL.QueryRowContext(c, query, id).Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.Priority, &t.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, gofrHTTP.ErrorEntityNotFound{Name: "task", Value: id}
}
if err != nil {
return nil, fmt.Errorf("failed to retrieve task: %w", err)
}
return t, nil
}
// updateTask handles PUT /tasks/{id}
func updateTask(c *gofr.Context) (any, error) {
id := c.PathParam("id")
if id == "" {
return nil, gofrHTTP.ErrorMissingParam{Params: []string{"id"}}
}
var update Task
if err := c.Bind(&update); err != nil {
return nil, gofrHTTP.ErrorInvalidParam{Params: []string{"body"}}
}
query := `UPDATE tasks SET title = COALESCE(NULLIF(?, ''), title),
description = COALESCE(NULLIF(?, ''), description),
status = COALESCE(NULLIF(?, ''), status),
priority = COALESCE(NULLIF(?, ''), priority)
WHERE id = ?`
res, err := c.SQL.ExecContext(c, query, update.Title, update.Description, update.Status, update.Priority, id)
if err != nil {
return nil, fmt.Errorf("failed to update task: %w", err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if rowsAffected == 0 {
return nil, gofrHTTP.ErrorEntityNotFound{Name: "task", Value: id}
}
return getTask(c)
}
// deleteTask handles DELETE /tasks/{id}
func deleteTask(c *gofr.Context) (any, error) {
id := c.PathParam("id")
if id == "" {
return nil, gofrHTTP.ErrorMissingParam{Params: []string{"id"}}
}
query := `DELETE FROM tasks WHERE id = ?`
res, err := c.SQL.ExecContext(c, query, id)
if err != nil {
return nil, fmt.Errorf("failed to delete task: %w", err)
}
rowsAffected, err := res.RowsAffected()
if err != nil {
return nil, err
}
if rowsAffected == 0 {
return nil, gofrHTTP.ErrorEntityNotFound{Name: "task", Value: id}
}
return nil, nil
}
Key GoFr Concepts Highlighted:
-
Uniform Handler Signature:
func(c *gofr.Context) (any, error). Any returned struct is automatically marshaled into standard JSON response format{"data": ...}. Returning errors automatically responds with appropriate HTTP status codes (201 for created resources, 204 for deletes, 404 for missing entities, 400 for bad parameters). -
Context Propagation: Always pass
c(*gofr.Context) toc.SQL.ExecContextandc.SQL.QueryContext. This preserves the OpenTelemetry active span across the database query. -
Built-in Error Types: GoFr provides rich standard HTTP error types like
gofrHTTP.ErrorEntityNotFound,gofrHTTP.ErrorMissingParam, andgofrHTTP.ErrorInvalidParam.
5. Running and Testing the Service
Run the microservice:
go run main.go
Startup output:
{"level":"INFO","time":"2026-10-30T13:57:00+05:30","message":"Loaded config from file: ./configs/.env","gofrVersion":"dev"}
{"level":"INFO","time":"2026-10-30T13:57:00+05:30","message":"connected to 'tasks.db' database","gofrVersion":"dev"}
{"level":"INFO","time":"2026-10-30T13:57:00+05:30","message":"running migration 1730300000","gofrVersion":"dev"}
{"level":"INFO","time":"2026-10-30T13:57:00+05:30","message":"Migration 1730300000 ran successfully","gofrVersion":"dev"}
{"level":"INFO","time":"2026-10-30T13:57:00+05:30","message":"Registered HTTP server on port: 9000","gofrVersion":"dev"}
{"level":"INFO","time":"2026-10-30T13:57:00+05:30","message":"Starting metrics server on port: 2121","gofrVersion":"dev"}
Testing the Endpoints
1. Create a Task
curl -i -X POST http://localhost:9000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Explore GoFr", "description": "Write article and run benchmarks", "priority": "high"}'
Response (HTTP/1.1 201 Created):
{
"data": {
"id": 1,
"title": "Explore GoFr",
"description": "Write article and run benchmarks",
"status": "pending",
"priority": "high"
}
}
Notice the server log output automatically generated by GoFr for this request:
{
"level": "INFO",
"time": "2026-10-30T13:57:00+05:30",
"message": {
"trace_id": "5d4b8e169c116689518fab5029799c45",
"span_id": "9af1f5a261da4ee6",
"start_time": "2026-10-30T13:57:00+05:30",
"response_time": 637,
"method": "POST",
"user_agent": "curl/8.7.1",
"ip": "127.0.0.1:52000",
"uri": "/tasks",
"response": 201
}
}
Every single request automatically gets a distributed trace_id and span_id without a single line of OpenTelemetry boilerplate!
2. Query Health Check (/.well-known/health)
curl -s http://localhost:9000/.well-known/health | jq
Response:
{
"data": {
"name": "task-api",
"status": "SUCCESS",
"version": "dev",
"details": {
"sql": {
"status": "UP",
"details": {
"host": "tasks.db",
"database": "tasks.db",
"stats": {
"openConnections": 1,
"inUse": 0,
"idle": 1
}
}
}
}
}
}
GoFr automatically pings the database and reports pool statistics in the health probe!
3. Custom Prometheus Metrics
Check the metrics endpoint:
curl -s http://localhost:2121/metrics | grep tasks_created_total
Output:
# HELP tasks_created_total
# TYPE tasks_created_total counter
tasks_created_total{app_name="task-api"} 1
6. Writing Integration Tests
GoFr provides a dedicated testutil package that reserves ephemeral ports and manages server lifecycle for deterministic integration tests.
Create main_test.go:
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gofr.dev/pkg/gofr/testutil"
)
func TestMain(m *testing.M) {
os.Setenv("GOFR_TELEMETRY", "false")
m.Run()
}
func TestIntegration_TaskAPI(t *testing.T) {
configs := testutil.NewServerConfigs(t)
go main()
testutil.WaitForHTTPServer(t, configs.HTTPHost)
client := http.Client{}
// 1. Create Task (201 Created)
createBody := []byte(`{"title":"Write GoFr Tutorial","description":"Complete article for open-source contribution","priority":"high"}`)
req, err := http.NewRequest(http.MethodPost, configs.HTTPHost+"/tasks", bytes.NewReader(createBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var createResp struct {
Data Task `json:"data"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&createResp))
assert.Equal(t, "Write GoFr Tutorial", createResp.Data.Title)
taskID := createResp.Data.ID
// 2. Get Task by ID (200 OK)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/tasks/%d", configs.HTTPHost, taskID), http.NoBody)
require.NoError(t, err)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// 3. Delete Task (204 No Content)
req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/tasks/%d", configs.HTTPHost, taskID), http.NoBody)
require.NoError(t, err)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// 4. Verify Task is gone (404 Not Found)
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/tasks/%d", configs.HTTPHost, taskID), http.NoBody)
require.NoError(t, err)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
// 5. Verify Health Check (200 OK)
req, err = http.NewRequest(http.MethodGet, configs.HTTPHost+"/.well-known/health", http.NoBody)
require.NoError(t, err)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
}
Run the tests:
go test -v ./...
Output:
=== RUN TestIntegration_TaskAPI
--- PASS: TestIntegration_TaskAPI (0.11s)
PASS
ok gofr-task-api 0.764s
Conclusion
GoFr dramatically reduces boilerplate in Go backend engineering. In less than 150 lines of code, we built an end-to-end microservice with:
- Fully automated SQLite migrations
- Idiomatic REST endpoints
- Automatic JSON structured logging with correlation IDs
- Out-of-the-box OpenTelemetry tracing and Prometheus metrics
- Production-grade health checks and pool monitoring
Check out the official GoFr documentation and repository to learn more:
- Tutorial GitHub Repository: github.com/manchitt/gofr-task-api
- Official Website & Docs: gofr.dev
- GitHub Repository: github.com/gofr-dev/gofr
- CNCF Landscape: CNCF App Definition and Development
Top comments (0)