DEV Community

Manchit Sachdeva
Manchit Sachdeva

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

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(),
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

Response (HTTP/1.1 201 Created):

{
  "data": {
    "id": 1,
    "title": "Explore GoFr",
    "description": "Write article and run benchmarks",
    "status": "pending",
    "priority": "high"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Output:

# HELP tasks_created_total
# TYPE tasks_created_total counter
tasks_created_total{app_name="task-api"} 1
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

Run the tests:

go test -v ./...
Enter fullscreen mode Exit fullscreen mode

Output:

=== RUN   TestIntegration_TaskAPI
--- PASS: TestIntegration_TaskAPI (0.11s)
PASS
ok      gofr-task-api   0.764s
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)