Building Production-Ready Microservices in Go with GoFr: Zero-Config Observability, SQLite & Migrations
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=sqlite informs GoFr's SQL datasource manager to initialize an embedded SQLite driver.
- HTTP_PORT=9000 sets the REST API server port.
- METRICS_PORT=2121 automatically 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:
1. 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).
2. Context Propagation: Always pass c (*gofr.Context) to c.SQL.ExecContext and c.SQL.QueryContext. This preserves the OpenTelemetry active span across the database query.
3. Built-in Error Types: GoFr provides rich standard HTTP error types like gofrHTTP.ErrorEntityNotFound, gofrHTTP.ErrorMissingParam, and gofrHTTP.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: manchitt/gofr-task-api
- Official Website & Docs: gofr.dev
- GitHub Repository: gofr-dev/gofr
- CNCF Landscape: CNCF App Definition and Development
A real Go service in 85 lines: link shortener with probes, metrics and traces
Every small Go service starts with the same chore list. You want to write the feature, but first you write the boring parts: structured logging, a metrics endpoint, health and readiness probes, configuration. None of it is hard. It is just the same work every time, and it usually gets finished after the first incident instead of before.
This post builds a small service people actually deploy: a link shortener. Create a short link, redirect clicks to the destination, count the clicks. I built it with GoFr, an opinionated Go microservice framework, to see how much of the production wiring I could skip.
Everything below is real output from a live run (GoFr v1.62.0, Go 1.26). The service is one file, 85 lines including imports and types.
What we are building
!What runs when you start the service: app server on :8000, probes, metrics on :2121, and structured logs
The API surface:
| Method | Route | What it does |
|---|---|---|
| POST | /links | Create a short link |
| GET | /{code} | 302 redirect to the target, increments the click count |
| GET | /links | List links with click counts |
And the parts we do not write ourselves: probes at /.well-known/health and /.well-known/alive, a Prometheus metrics server on port 2121, and JSON logs on stdout with trace ids.
Setup
mkdir shortlink && cd shortlink
go mod init shortlink
go get gofr.dev/pkg/gofr
GoFr v1.62 targets Go 1.26 or newer.
The whole service: three routes, one counter
Imports first. The three gofr.dev packages are the framework itself, its HTTP error types, and its response helpers.
import (
"errors"
"net/url"
"strconv"
"sync"
"gofr.dev/pkg/gofr"
gofrHTTP "gofr.dev/pkg/gofr/http"
"gofr.dev/pkg/gofr/http/response"
)
The state is deliberately boring: a map of codes to links, an ordered slice for listing, and a counter that produces short codes in base36, so the first code is rs.
type Link struct {
Code string `json:"code"`
URL string `json:"url"`
Clicks int `json:"clicks"`
}
var (
mu sync.Mutex
byCode = map[string]*Link{}
ordered []*Link
next = 1000
)
Creating a link looks like this:
func createLink(c *gofr.Context) (any, error) {
in := struct {
URL string `json:"url"`
}{}
if err := c.Bind(&in); err != nil {
return nil, err
}
if _, err := url.ParseRequestURI(in.URL); err != nil {
return nil, errors.New("url must be an absolute URL, e.g. https://example.com")
}
mu.Lock()
l := &Link{Code: strconv.FormatInt(int64(next), 36), URL: in.URL}
next++
byCode[l.Code] = l
ordered = append(ordered, l)
mu.Unlock()
c.Logf("created link %s -> %s", l.Code, l.URL)
return l, nil
}
A few details that matter later:
- The handler signature is func(c *gofr.Context) (any, error). What you return becomes the JSON body, and a non-nil error becomes a JSON error response.
- c.Bind decodes the request body into a struct.
- c.Logf writes a structured log line that carries the current request's trace id.
The redirect is where the framework does something I would otherwise write by hand. Returning response.Redirect makes the framework reply 302 (or 303 for a POST) with the Location header set:
func openLink(c *gofr.Context) (any, error) {
mu.Lock()
l, ok := byCode[c.PathParam("code")]
if ok {
l.Clicks++
}
mu.Unlock()
if !ok {
return nil, gofrHTTP.ErrorEntityNotFound{Name: "link", Value: c.PathParam("code")}
}
return response.Redirect{URL: l.URL}, nil
}
Note the typed error. By default a returned error becomes a 500. ErrorEntityNotFound is how you return a proper 404 instead. Small thing, but it is the difference between "the service is broken" and "this link does not exist" for every dashboard that watches your error rate.
Listing and main are short:
func listLinks(c *gofr.Context) (any, error) {
mu.Lock()
defer mu.Unlock()
out := make([]Link, 0, len(ordered))
for _, l := range ordered {
out = append(out, *l)
}
return out, nil
}
func main() {
app := gofr.New()
app.POST("/links", createLink)
app.GET("/links", listLinks)
app.GET("/{code}", openLink)
app.Run()
}
Running it: the real outputs
$ curl -s -i -X POST localhost:8000/links -H 'Content-Type: application/json' -d '{"url":"https://gofr.dev"}'
HTTP/1.1 201 Created
{"data":{"code":"rs","url":"https://gofr.dev","clicks":0}}
$ curl -s -i localhost:8000/rs
HTTP/1.1 302 Found
Location: https://gofr.dev
$ curl -s localhost:8000/links
{"data":[{"code":"rs","url":"https://gofr.dev","clicks":2},{"code":"rt","url":"https://github.com/gofr-dev/gofr","clicks":0}]}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/nope
404
$ curl -s localhost:8000/nope
{"error":{"message":"No entity found with link: nope"}}
Responses are wrapped in a data envelope, and errors use an error envelope.
If your API contract is already fixed you can opt out with response.Raw, but the defaults are consistent, which is the point.
What came for free
These are the parts I normally write during week two, or not at all.
Traced structured logs
Two real lines from a single request cycle, timestamps trimmed for width:
{"level":"INFO","time":"2026-10-19T08:36:44.176Z","message":"created link rs -\u003e https://gofr.dev","trace_id":"1cf499e56bec5202ccf8da6c5aff62d0","gofrVersion":"v1.62.0"}
{"level":"INFO","time":"2026-10-19T08:36:44.196Z","message":{"trace_id":"1c28113c51c43d36fb894071ccd267b1","span_id":"9668b5265c0af36e","response_time":671,"method":"GET","user_agent":"curl/8.14.1","uri":"/rs","response":302},"gofrVersion":"v1.62.0"}
The first line is the c.Logf call inside the handler. The second is the framework's own request log. They carry the same shape of trace context with no middleware to add, and every request gets a response_time.
Metrics on a separate port
http://localhost:2121/metrics serves Prometheus metrics. Runtime metrics are there (goroutines, garbage collection), and every route gets a response-time histogram labeled by route pattern rather than concrete URL, which keeps cardinality under control:
# HELP app_http_response Response time of HTTP requests in seconds.
# TYPE app_http_response histogram
app_http_response_bucket{method="GET",path="/{code}",status="302",le="0.001"} 2
app_http_response_bucket{method="GET",path="/{code}",status="302",le="0.003"} 2
app_http_response_bucket{method="GET",path="/links",status="200",le="0.001"} 1
Because the path label is the route template, every route you add shows up in your dashboards without per-endpoint scrape config.
Probes that are already wired
$ curl -s localhost:8000/.well-known/health
{"data":{"name":"gofr-app","status":"UP"}}
$ curl -s localhost:8000/.well-known/alive
{"data":{"status":"UP"}}
/.well-known/health is the readiness endpoint. Add a database or cache and its health plugs into this check, and the endpoint answers 503 when something is down. /.well-known/alive is a cheap liveness endpoint. On Kubernetes the probe config is two short blocks:
readinessProbe:
httpGet: { path: /.well-known/health, port: 8000 }
livenessProbe:
httpGet: { path: /.well-known/alive, port: 8000 }
The honest part
A framework that promises convenience always charges somewhere. What I would tell a teammate before adopting GoFr:
- You inherit its conventions. The data and error envelopes, the router, the config loading. You can override pieces (raw responses, custom logger, custom config provider), but if you fight the defaults you are paying the framework tax without getting the benefit.
- It is opinionated about recency. v1.62 targets Go 1.26. On an older toolchain you pin an older release.
- This demo keeps links in memory. For real storage you plug in a datasource. The framework exposes SQL and Redis handles on the context, but persistence is the part of this post you would rewrite.
- It is a framework, not a library. For a two-endpoint service that will never grow, plain net/http is completely fine.
For the "small service that has to behave in production" case, though, this is the least wiring I have written for something deployable.
Wrapping up
The whole thing: 85 lines in one file, plus probes, metrics and traces that arrived without a line of glue code.
The parts that usually wait for the first incident were already there when I ran go run .
If you want to dig further:
- Docs: gofr.dev
- Repo: gofr-dev/gofr (Apache-2.0)
One question, because I am curious what people actually prioritize: in a new Go service, do you wire logs, metrics or probes first?
Mentions
Building Production-Ready Microservices in Go with GoFr: Zero-Config Observability, SQLite & Migrations 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=sqlite informs GoFr's SQL datasource manager to initialize an embedded SQLite driver. - HTTP_PORT=9000 sets the REST API server port. - METRICS_PORT=2121 automatically 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: 1. 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). 2. Context Propagation: Always pass c (*gofr.Context) to c.SQL.ExecContext and c.SQL.QueryContext. This preserves the OpenTelemetry active span across the database query. 3. Built-in Error Types: GoFr provides rich standard HTTP error types like gofrHTTP.ErrorEntityNotFound, gofrHTTP.ErrorMissingParam, and gofrHTTP.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: manchitt/gofr-task-api - Official Website & Docs: gofr.dev - GitHub Repository: gofr-dev/gofr - CNCF Landscape: CNCF App Definition and Development
A real Go service in 85 lines: link shortener with probes, metrics and traces Every small Go service starts with the same chore list. You want to write the feature, but first you write the boring parts: structured logging, a metrics endpoint, health and readiness probes, configuration. None of it is hard. It is just the same work every time, and it usually gets finished after the first incident instead of before. This post builds a small service people actually deploy: a link shortener. Create a short link, redirect clicks to the destination, count the clicks. I built it with GoFr, an opinionated Go microservice framework, to see how much of the production wiring I could skip. Everything below is real output from a live run (GoFr v1.62.0, Go 1.26). The service is one file, 85 lines including imports and types. What we are building !What runs when you start the service: app server on :8000, probes, metrics on :2121, and structured logs The API surface: | Method | Route | What it does | |---|---|---| | POST | /links | Create a short link | | GET | /{code} | 302 redirect to the target, increments the click count | | GET | /links | List links with click counts | And the parts we do not write ourselves: probes at /.well-known/health and /.well-known/alive, a Prometheus metrics server on port 2121, and JSON logs on stdout with trace ids. Setup mkdir shortlink && cd shortlink go mod init shortlink go get gofr.dev/pkg/gofr GoFr v1.62 targets Go 1.26 or newer. The whole service: three routes, one counter Imports first. The three gofr.dev packages are the framework itself, its HTTP error types, and its response helpers. import ( "errors" "net/url" "strconv" "sync" "gofr.dev/pkg/gofr" gofrHTTP "gofr.dev/pkg/gofr/http" "gofr.dev/pkg/gofr/http/response" ) The state is deliberately boring: a map of codes to links, an ordered slice for listing, and a counter that produces short codes in base36, so the first code is rs. type Link struct { Code string `json:"code"` URL string `json:"url"` Clicks int `json:"clicks"` } var ( mu sync.Mutex byCode = map[string]*Link{} ordered []*Link next = 1000 ) Creating a link looks like this: func createLink(c *gofr.Context) (any, error) { in := struct { URL string `json:"url"` }{} if err := c.Bind(&in); err != nil { return nil, err } if _, err := url.ParseRequestURI(in.URL); err != nil { return nil, errors.New("url must be an absolute URL, e.g. https://example.com") } mu.Lock() l := &Link{Code: strconv.FormatInt(int64(next), 36), URL: in.URL} next++ byCode[l.Code] = l ordered = append(ordered, l) mu.Unlock() c.Logf("created link %s -> %s", l.Code, l.URL) return l, nil } A few details that matter later: - The handler signature is func(c *gofr.Context) (any, error). What you return becomes the JSON body, and a non-nil error becomes a JSON error response. - c.Bind decodes the request body into a struct. - c.Logf writes a structured log line that carries the current request's trace id. The redirect is where the framework does something I would otherwise write by hand. Returning response.Redirect makes the framework reply 302 (or 303 for a POST) with the Location header set: func openLink(c *gofr.Context) (any, error) { mu.Lock() l, ok := byCode[c.PathParam("code")] if ok { l.Clicks++ } mu.Unlock() if !ok { return nil, gofrHTTP.ErrorEntityNotFound{Name: "link", Value: c.PathParam("code")} } return response.Redirect{URL: l.URL}, nil } Note the typed error. By default a returned error becomes a 500. ErrorEntityNotFound is how you return a proper 404 instead. Small thing, but it is the difference between "the service is broken" and "this link does not exist" for every dashboard that watches your error rate. Listing and main are short: func listLinks(c *gofr.Context) (any, error) { mu.Lock() defer mu.Unlock() out := make([]Link, 0, len(ordered)) for _, l := range ordered { out = append(out, *l) } return out, nil } func main() { app := gofr.New() app.POST("/links", createLink) app.GET("/links", listLinks) app.GET("/{code}", openLink) app.Run() } Running it: the real outputs $ curl -s -i -X POST localhost:8000/links -H 'Content-Type: application/json' -d '{"url":"https://gofr.dev"}' HTTP/1.1 201 Created {"data":{"code":"rs","url":"https://gofr.dev","clicks":0}} $ curl -s -i localhost:8000/rs HTTP/1.1 302 Found Location: https://gofr.dev $ curl -s localhost:8000/links {"data":[{"code":"rs","url":"https://gofr.dev","clicks":2},{"code":"rt","url":"https://github.com/gofr-dev/gofr","clicks":0}]} $ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/nope 404 $ curl -s localhost:8000/nope {"error":{"message":"No entity found with link: nope"}} Responses are wrapped in a data envelope, and errors use an error envelope. If your API contract is already fixed you can opt out with response.Raw, but the defaults are consistent, which is the point. What came for free These are the parts I normally write during week two, or not at all. Traced structured logs Two real lines from a single request cycle, timestamps trimmed for width: {"level":"INFO","time":"2026-10-19T08:36:44.176Z","message":"created link rs -\u003e https://gofr.dev","trace_id":"1cf499e56bec5202ccf8da6c5aff62d0","gofrVersion":"v1.62.0"} {"level":"INFO","time":"2026-10-19T08:36:44.196Z","message":{"trace_id":"1c28113c51c43d36fb894071ccd267b1","span_id":"9668b5265c0af36e","response_time":671,"method":"GET","user_agent":"curl/8.14.1","uri":"/rs","response":302},"gofrVersion":"v1.62.0"} The first line is the c.Logf call inside the handler. The second is the framework's own request log. They carry the same shape of trace context with no middleware to add, and every request gets a response_time. Metrics on a separate port http://localhost:2121/metrics serves Prometheus metrics. Runtime metrics are there (goroutines, garbage collection), and every route gets a response-time histogram labeled by route pattern rather than concrete URL, which keeps cardinality under control: # HELP app_http_response Response time of HTTP requests in seconds. # TYPE app_http_response histogram app_http_response_bucket{method="GET",path="/{code}",status="302",le="0.001"} 2 app_http_response_bucket{method="GET",path="/{code}",status="302",le="0.003"} 2 app_http_response_bucket{method="GET",path="/links",status="200",le="0.001"} 1 Because the path label is the route template, every route you add shows up in your dashboards without per-endpoint scrape config. Probes that are already wired $ curl -s localhost:8000/.well-known/health {"data":{"name":"gofr-app","status":"UP"}} $ curl -s localhost:8000/.well-known/alive {"data":{"status":"UP"}} /.well-known/health is the readiness endpoint. Add a database or cache and its health plugs into this check, and the endpoint answers 503 when something is down. /.well-known/alive is a cheap liveness endpoint. On Kubernetes the probe config is two short blocks: readinessProbe: httpGet: { path: /.well-known/health, port: 8000 } livenessProbe: httpGet: { path: /.well-known/alive, port: 8000 } The honest part A framework that promises convenience always charges somewhere. What I would tell a teammate before adopting GoFr: - You inherit its conventions. The data and error envelopes, the router, the config loading. You can override pieces (raw responses, custom logger, custom config provider), but if you fight the defaults you are paying the framework tax without getting the benefit. - It is opinionated about recency. v1.62 targets Go 1.26. On an older toolchain you pin an older release. - This demo keeps links in memory. For real storage you plug in a datasource. The framework exposes SQL and Redis handles on the context, but persistence is the part of this post you would rewrite. - It is a framework, not a library. For a two-endpoint service that will never grow, plain net/http is completely fine. For the "small service that has to behave in production" case, though, this is the least wiring I have written for something deployable. Wrapping up The whole thing: 85 lines in one file, plus probes, metrics and traces that arrived without a line of glue code. The parts that usually wait for the first incident were already there when I ran go run . If you want to dig further: - Docs: gofr.dev - Repo: gofr-dev/gofr (Apache-2.0) One question, because I am curious what people actually prioritize: in a new Go service, do you wire logs, metrics or probes first?
The full write-up, with the prompt, the code and the review table: medium.com GoFr is open source (Apache-2.0): gofr-dev/gofr