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
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
}
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
}
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(),
}
}
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
},
}
}
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
}
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"`
}
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)
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
}
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
}
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}
}
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
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) {
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:
- NoSQL datasources: In addition to SQL databases, GoFr can connect to NoSQL databases like MongoDB and key/value stores through a unified datasource interface.
- gRPC server and client support: GoFr can generate and host gRPC services with framework context support, observability, interceptors, and health checks.
- GraphQL: GoFr supports schema-first GraphQL APIs with resolver registration, a built-in playground, and automatic tracing and metrics.
- Websockets: GoFr can expose WebSocket endpoints and also connect to other WebSocket services for real-time messaging.
- Pub/Sub: GoFr supports asynchronous publish-subscribe workflows across brokers like Kafka, Google Pub/Sub, MQTT, NATS, Redis, Azure Event Hubs, and Amazon SQS.
- Service-to-service HTTP: GoFr can register downstream HTTP services with built-in tracing, retries, rate limiting, auth options, and health checks.
- Circuit breaker: GoFr can protect outbound HTTP calls by opening the circuit after repeated failures and probing dependency health before resuming traffic.
- Startup hooks: GoFr lets you run synchronous startup tasks such as cache warming or boot-time validation before the app begins accepting requests.
- Custom metrics: GoFr lets you publish your own counters, gauges, histograms, and up-down counters with labels for application-specific monitoring.
- File handling: GoFr provides a uniform file API for local storage, FTP, SFTP, and cloud backends such as S3, GCS, and Azure File Storage.
- Remote log level change: GoFr can fetch log-level settings from a remote endpoint so you can increase or reduce logging without redeploying the service.
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.