Home | Send Feedback | Share on Bluesky |

Building Microservices with GoFr

Published: 11. May 2026  •  go

GoFr is an opinionated Go framework for building microservices. With GoFr, you can build HTTP APIs, gRPC services, GraphQL APIs, pub-sub workers, and more. The framework provides a batteries-included experience for common backend features, so you can focus on your business logic. Out of the box you get support for configuration, migrations, SQL access, logging, metrics, tracing, testing, and API documentation.

This post shows how to build a small JSON HTTP API with GoFr and PostgreSQL.

Setup

The demo uses PostgreSQL 18 running in a Docker container. I'm using this Docker Compose file to start the database. GoFr is not limited to PostgreSQL; it can work with SQL databases through its datasource support, and it also supports NoSQL and other backends.

GoFr can read configuration from environment variables, or from a .env file placed in the configs/ directory at the project root. This example uses a .env file. In this demo, the app and database configuration looks like this:

APP_NAME=gofr-launch-ideas
APP_ENV=dev
HTTP_PORT=8000
METRICS_PORT=2121
LOG_LEVEL=INFO

DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=gofr_demo
DB_DIALECT=postgres
DB_SSL_MODE=disable

.env

We see the typical database configuration parameters here, along with some app-level settings. According to the GoFr configuration reference, APP_ENV is used to select the environment file to load, for example local.env, stage.env, or prod.env.

Accessing this configuration in the code is easy. GoFr loads the configuration when the app starts, and you can read it with c.Config("KEY") in handlers or app.Config("KEY") in the app setup. For example, to read the environment variable in a handler, you could do:

func handler(c *gofr.Context) (any, error) {
	env := c.Config("APP_ENV")
	c.Infof("app is running in %s environment", env)
	// ...
}

Run the app

You can find the source code for the demo in this GitHub repository. To run the app, first start the PostgreSQL database with:

docker compose up -d

Once the database is running, start the GoFr service with:

go run .

Then you can call the API locally at http://localhost:8000. For example, to list ideas, you can run:

curl http://localhost:8000/ideas

There is also a Go client demo that shows how to call the API from a Go program.

App Initialization

The application setup is small. gofr.New() creates the app, Migrate runs the database migrations, and the HTTP routes are registered with their handlers.

func newApp() *gofr.App {
  app := gofr.New()

  app.Migrate(migrations.All())

  app.GET("/ideas", listIdeas)
  app.GET("/ideas/{id}", getIdea)
  app.POST("/ideas", createIdea)
  app.POST("/ideas/{id}/boost", boostIdea)
  app.DELETE("/ideas/{id}", deleteIdea)

  return app
}

main.go

GoFr also supports custom middleware. GoFr already wires in common framework features such as logging, metrics, and tracing, so you only need custom middleware for application-specific concerns.

For example, this middleware wraps the underlying http.Handler and can run logic before and after the wrapped handler:

	app.UseMiddleware(func(inner http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// Do something before the handler runs

			inner.ServeHTTP(w, r)

			// Do something after the handler runs
		})
	})

Handlers

The handler signature follows this pattern.

func handler(c *gofr.Context) (any, error)

The context is the central object. It gives the handler access to the request, response, configuration, logger, SQL datasource, and more. The handler returns a response object and an error.

Logging

GoFr gives each request a logger through the context, so handlers and middleware can write application logs with methods such as c.Info, c.Warn, c.Error, and their formatted variants. The framework also emits its own logs for startup, configuration loading, incoming requests, and SQL activity, which makes it easy to see what the service is doing without wiring up a logger yourself.

The log level is controlled with the LOG_LEVEL environment variable. The default is INFO. If you need more detail while debugging, you can temporarily switch it to DEBUG.

func getIdea(c *gofr.Context) (any, error) {
  defer c.Trace("getIdea").End()

  id, err := ideaID(c)
  if err != nil {
    c.Warnf("invalid idea id: %q", c.PathParam("id"))
    return nil, err
  }

  idea, err := queryIdea(c, getIdeaQuery, id)
  if err != nil {
    c.Errorf("failed to load idea %d: %v", id, err)
    return nil, err
  }

  c.Infof("loaded idea %d", idea.ID)

  return map[string]any{
    "idea": idea,
  }, nil
}

main.go

GoFr also includes request metadata such as correlation IDs, status codes, and request timing in its built-in logs, which is useful when you are following a request across multiple handlers or services.

Tracing

GoFr builds tracing on top of OpenTelemetry. In the demo handlers, defer c.Trace("name").End() creates a custom span around the handler logic. GoFr also adds automatic tracing around incoming requests and propagates correlation IDs and trace context across supported integrations.

GoFr supports otlp, jaeger, zipkin, and gofr exporters. OTLP is the recommended default for new setups. For example, to send traces to Jaeger, you can set:

TRACE_EXPORTER=jaeger
TRACER_URL=localhost:14317
TRACER_RATIO=1.0

GoFr can also send traces to OTLP-compatible backends such as Grafana Cloud or an OpenTelemetry Collector, and it supports TRACER_HEADERS for authenticated exporters.

DB Migration

Programmatic database migrations are, in my opinion, a must-have for any service that uses a database, and GoFr has a built-in migration system. Migrations are Go functions that run statements through a datasource. The important part is that they are registered in the migrations package. As we saw above, you can run the migrations at app startup with app.Migrate(migrations.All()).

func All() map[int64]migration.Migrate {
  return map[int64]migration.Migrate{
    202603150001: addLaunchIdeasFeature(),
  }
}

all.go

The order is determined by the keys, which the official docs recommend storing as timestamps in YYYYMMDDHHMMSS format. The values are migration objects with UP and optional DOWN functions.

const launchIdeasFeature = `
CREATE TABLE IF NOT EXISTS launch_ideas (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title TEXT NOT NULL,
  pitch TEXT NOT NULL,
  stage TEXT NOT NULL,
  hype_score INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_boosted_at TIMESTAMPTZ
);

INSERT INTO launch_ideas (title, pitch, stage, hype_score, last_boosted_at)
VALUES
  ('AI release notes from commits', 'Turns merged pull requests into human-readable release notes.', 'prototype', 3, NOW() - INTERVAL '2 hours'),
  ('Stand-up summarizer for Slack', 'Builds a daily stand-up digest from team updates and blockers.', 'beta', 5, NOW() - INTERVAL '30 minutes'),
  ('Incident timeline generator', 'Pulls deploys, alerts and commits into one incident timeline.', 'discovery', 1, NULL)
ON CONFLICT DO NOTHING;
`

func addLaunchIdeasFeature() migration.Migrate {
  return migration.Migrate{
    UP: func(d migration.Datasource) error {
      _, err := d.SQL.Exec(launchIdeasFeature)
      return err
    },
  }
}

202603150001_launch_ideas.go

GoFr also ships a CLI that can create timestamped migration templates for you. To install the CLI, run:

go install gofr.dev/cli/gofr@latest

To create a new migration template, run:

gofr migrate create -name=create_launch_ideas

If you have migrations configured at app startup and you run multiple instances of the app at the same time, GoFr coordinates them automatically. The official docs describe a locking mechanism so that only one instance applies the migrations while the others wait.

Read the documentation for more details and examples on using migrations with GoFr.

Query Parameters

Reading query parameters is straightforward. GoFr supports both single and repeated query parameters. For repeated parameters, you can either repeat the parameter multiple times or pass comma-separated values.

If we call the list ideas endpoint with the following query:

curl "http://localhost:8000/ideas?stage=prototype,beta&q=release&minHype=2&limit=5"

The handler can read the parameters with c.Param("name") for single values or c.Params("name") for repeated values. In this example, the list handler parses the parameters into a struct.

func parseListIdeasFilter(c *gofr.Context) (listIdeasFilter, error) {
  filter := listIdeasFilter{
    Stages: cleanStrings(c.Params("stage")),
    Query:  strings.TrimSpace(c.Param("q")),
    Limit:  20,
  }

  if raw := strings.TrimSpace(c.Param("minHype")); raw != "" {
    minHype, err := strconv.Atoi(raw)
    if err != nil || minHype < 0 {
      return listIdeasFilter{}, badRequest("minHype must be a non-negative integer")
    }

    filter.MinHype = &minHype
  }

  if raw := strings.TrimSpace(c.Param("limit")); raw != "" {
    limit, err := strconv.Atoi(raw)
    if err != nil || limit < 1 || limit > 100 {
      return listIdeasFilter{}, badRequest("limit must be between 1 and 100")
    }

    filter.Limit = limit
  }

  return filter, nil
}

main.go

Request Body

Handling JSON request bodies is also straightforward. Create a struct that matches the expected JSON shape, and then call c.Bind(&req) in the handler. GoFr reads the request body and fills the struct based on the JSON tags.

type createIdeaRequest struct {
  Title string `json:"title"`
  Pitch string `json:"pitch"`
  Stage string `json:"stage"`
}

main.go

func createIdea(c *gofr.Context) (any, error) {
  defer c.Trace("createIdea").End()

  var req createIdeaRequest
  if err := c.Bind(&req); err != nil {
    return nil, err
  }

  req.Title = strings.TrimSpace(req.Title)
  req.Pitch = strings.TrimSpace(req.Pitch)
  req.Stage = strings.TrimSpace(req.Stage)

main.go

Path Parameters

For path parameters, GoFr uses the {id} syntax in route definitions. Handlers can read them with c.PathParam("id"). In this example the ID helper reads c.PathParam("id") and converts it to an integer.

func ideaID(c *gofr.Context) (int64, error) {
  id, err := strconv.ParseInt(c.PathParam("id"), 10, 64)
  if err != nil || id < 1 {
    return 0, badRequest("invalid idea id")
  }

  return id, nil
}

main.go

Handler response

Handlers in GoFr return ordinary Go values and errors. The framework takes care of encoding regular responses as JSON. If you want to return a specific status code, you can set it in the context before returning. For example, to return 202 Accepted, you could write:

	c.SetStatus(http.StatusAccepted)
	return map[string]any{
		"message": "idea created",
		"idea":    idea,
	}, nil

Status Codes from Errors

Handlers return ordinary Go errors. In this demo, helper errors implement StatusCode() int, which allows GoFr to map them to the corresponding HTTP response code.

type httpError struct {
  status  int
  message string
}

func (e httpError) Error() string {
  return e.message
}

func (e httpError) StatusCode() int {
  return e.status
}

main.go

So in this example badRequest returns an error with a 400 Bad Request status code, and normalizeSQLError converts a sql.ErrNoRows into a 404 Not Found error.

func normalizeSQLError(err error) error {
  if errors.Is(err, sql.ErrNoRows) {
    return httpError{status: http.StatusNotFound, message: "idea not found"}
  }

  return err
}

func badRequest(message string) error {
  return httpError{status: http.StatusBadRequest, message: message}
}

main.go

Health, Metrics, and Docs

GoFr registers health routes when the HTTP server starts. You can access them at /.well-known/alive and /.well-known/health.

curl http://localhost:8000/.well-known/alive
curl http://localhost:8000/.well-known/health

/.well-known/alive returns a simple UP response, while /.well-known/health includes dependency status for connected datasources and services.

The metrics server runs separately on the configured metrics port. By default, GoFr exposes Prometheus-format metrics on port 2121.

curl http://localhost:2121/metrics

That endpoint exposes Prometheus-format metrics, including HTTP, SQL, Pub/Sub, and other framework metrics.

To disable the metrics server, set METRICS_PORT to 0 in your configuration.


GoFr can also render Swagger UI automatically when you place an openapi.json file in the project's static directory.

curl http://localhost:8000/.well-known/swagger

OpenAPI

GoFr does not generate an OpenAPI document for you; it renders Swagger UI when you provide an openapi.json file. So you can either write the document by hand or generate it with a tool. In this project, the OpenAPI document is generated with Swaggo based on comments in the code.

Here are a few examples of the comments that Swaggo can parse.

// @title Launch Ideas API
// @version 1.0
// @description A small GoFr demo API for managing launch ideas.
// @BasePath /
// @schemes http
package main

main.go

Each handler also gets operation-level annotations. For example, listIdeas is documented like this:

// listIdeas godoc
// @Summary List ideas
// @Description Returns ideas with optional stage, query, minimum hype, and limit filters.
// @Tags ideas
// @Produce json
// @Param stage query string false "Stage filter. Repeat the parameter or pass comma-separated values."
// @Param q query string false "Case-insensitive text search over title and pitch"
// @Param minHype query int false "Minimum hype score"
// @Param limit query int false "Maximum number of ideas to return"
// @Success 200 {object} APIListIdeasResponse
// @Failure 400 {object} APIErrorResponse
// @Router /ideas [get]
func listIdeas(c *gofr.Context) (any, error) {

main.go

SQL

GoFr has a built-in SQL datasource that provides connection pooling and context-aware query methods. The datasource is available in handlers as c.SQL. You can use it to execute queries and transactions. The datasource also integrates with GoFr's observability features, so SQL activity shows up in logs and metrics out of the box.

query := "SELECT id, title, pitch, stage, hype_score, created_at, last_boosted_at FROM launch_ideas WHERE id = $1"
func queryIdea(c *gofr.Context, query string, args ...any) (ideaResponse, error) {
	row := c.SQL.QueryRowContext(c, query, args...)

	var idea ideaResponse
	err := row.Scan(&idea.ID, &idea.Title, &idea.Pitch, &idea.Stage, &idea.HypeScore, &idea.CreatedAt, &idea.LastBoostedAt)
	if err != nil {
		return ideaResponse{}, normalizeSQLError(err)
	}

	return idea, nil
}

query is a standard SQL query string with placeholders ($1, $2, etc.) for parameters. args are the parameters to fill into the query. The QueryRowContext method executes the query and returns a single row, which you can scan into a struct or variables. If the query returns no rows, row.Scan will return sql.ErrNoRows, which you can handle as needed (for example, by returning a 404 Not Found error).

Scheduled Tasks

GoFr also supports cron-style scheduled tasks. You register them with app.AddCronJob(schedule, jobName, func(ctx *gofr.Context)).

For example, the following job deletes stale ideas every night at 02:00:

	app := gofr.New()

	app.AddCronJob("0 2 * * *", "cleanup-stale-ideas", func(ctx *gofr.Context) {
		const query = `DELETE FROM launch_ideas WHERE stage = 'archived' AND created_at < NOW() - INTERVAL '30 days'`

		res, err := ctx.SQL.ExecContext(ctx, query)
		if err != nil {
			ctx.Logger.Errorf("cleanup job failed: %v", err)
			return
		}

		rowsAffected, _ := res.RowsAffected()
		ctx.Logger.Infof("cleanup job removed %d archived ideas", rowsAffected)
	})

The schedule above uses the standard 5-field cron format: minute hour day_of_month month day_of_week. GoFr also supports an optional leading second field if you need sub-minute scheduling, for example */10 * * * * * for every 10 seconds.

GoFr automatically publishes cron job metrics such as app_cron_job_total, app_cron_job_success, app_cron_job_failures, and app_cron_job_duration, labeled with the job name.

Authentication

The framework has built-in support for authentication and authorization. The official documentation describes Basic Auth, API key auth, and OAuth 2.0, and the auth model applies to both HTTP and gRPC services.

For example, to implement Basic Auth, you can use the following code:

	app := gofr.New()
	app.EnableBasicAuth("admin", "secret_password")

Check out the documentation for more details and examples.

Authorization

GoFr provides a pure config-based RBAC middleware that supports multiple authentication methods, fine-grained permissions, and role inheritance. You define the rules in a JSON or YAML file:

{
  "roleHeader": "X-User-Role",
  "roles": [
    {
      "name": "admin",
      "permissions": ["users:read", "users:write", "users:delete", "posts:read", "posts:write"]
    },
    {
      "name": "editor",
      "permissions": ["users:write", "posts:write"],
      "inheritsFrom": ["viewer"]
    },
    {
      "name": "viewer",
      "permissions": ["users:read", "posts:read"]
    }
  ],
  "endpoints": [
    {
      "path": "/health",
      "methods": ["GET"],
      "public": true
    },
    {
      "path": "/api/users",
      "methods": ["GET"],
      "requiredPermissions": ["users:read"]
    },
    {
      "path": "/api/users",
      "methods": ["POST"],
      "requiredPermissions": ["users:write"]
    }
  ]
}

Then you load the rules with the EnableRBAC method. By default, it looks for config files in the configs directory with the name rbac.json, rbac.yaml, or rbac.yml. You can also provide a custom path if you want to store the config file somewhere else or with a different name.

    // loads RBAC rules from default locations
	app.EnableRBAC()
	
	// Or loads RBAC rules from a custom path
	app.EnableRBAC("configs/custom-rbac.json")

After the RBAC middleware is enabled, it checks incoming requests against the rules and enforces the required permissions based on the user's role. If a request does not have the necessary permissions, the middleware returns a 403 Forbidden response before it reaches the handler. Routes that are not listed in the RBAC config are allowed to proceed normally.

See the documentation for more details and examples on authorization with GoFr.

Tests

GoFr has a built-in mock container that can be used for unit testing handlers. The mock container provides a mock SQL datasource, so you can set up expected queries and results for your tests without starting a real database. The official testing docs also mention support for mocking other stores and HTTP services.

func TestCreateIdea(t *testing.T) {
	mockContainer, mocks := container.NewMockContainer(t)
	ctx := newTestContext(t, mockContainer, testRequest{
		method: http.MethodPost,
		path:   "/ideas",
		body:   `{"title":"Latency radar","pitch":"Explains slow endpoints before a customer notices.","stage":"beta"}`,
	})

	mocks.SQL.ExpectQuery(createIdeaQuery).
		WithArgs("Latency radar", "Explains slow endpoints before a customer notices.", "beta").
		WillReturnRows(mocks.SQL.NewRows([]string{"id", "title", "pitch", "stage", "hype_score", "created_at", "last_boosted_at"}).
			AddRow(int64(42), "Latency radar", "Explains slow endpoints before a customer notices.", "beta", 0, "2026-03-15 10:00:00+00", nil))

	resp, err := createIdea(ctx)
	// assertions omitted
}

See the documentation for more details and examples on testing with GoFr.

More features

GoFr has many more features that are not covered in this post, including:

Wrapping Up

GoFr is a mature framework for building Go microservices with many common features already wired together. It gives you a solid starting point for production services, with support for configuration, migrations, SQL access, logging, metrics, tracing, testing, and more.

Check out the documentation for more details and examples on using GoFr. The source code of GoFr is available on GitHub, and you can ask questions or join the discussion in the GoFr Discord community.