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, Source:https://dev.to/manchitt/building-production-ready-microservices-in-go-with-gofr-zero-config-observability-sqlite--80fRelated posts
DeepSeek's Open-Source "Super-Agent" Codes Without Being Asked
All-in-one platform or a stack you assemble? Where do coaches and educators land?
I Wanted to Live on Coruscant. PAT Scared Me. Now My Alexa Lives on Mute.
Detect What You Can Name: PaliGemma 2 Object Detection with Transformers
When Should an LLM Router Stop Being Sticky? llm-d 1.0 Cache-Affinity Routing, Break-Even Math, and a Simulation
