I wanted a realistic mini-project, so I picked something close to home: booking surf lessons in Varkala. I used it as an excuse to try GoFr, an opinionated Go framework for microservices.
The goal was simple:
list our instructors
book a lesson for a date and a slot (morning or evening, because that's when the waves are good)
stop two guests from booking the same instructor for the same slot
cancel a booking
All the code is here: https://github.com/ajassaif/surf-api
The whole app fits in one screen
This is the entire main:
go
func main() {
app := gofr.New()
app.Migrate(migrations.All())
app.GET("/instructors", listInstructors)
app.GET("/bookings", listBookings)
app.POST("/bookings", createBooking)
app.DELETE("/bookings/{id}", cancelBooking)
app.Run()
}
There's no router setup, no DB connection code and no logger wiring. GoFr reads configs/.env:
dotenv
APP_NAME=surf-api
HTTP_PORT=8000
DB_DIALECT=sqlite
DB_NAME=surf.db
…and on startup it connects to SQLite (creating the file if needed), runs the migrations, and starts the server. The startup logs say exactly that:
text
Loaded config from file: ./configs/.env
connected to 'surf.db' database
running migration 20260930180000
Migration 20260930180000 ran successfully
Starting server on port: 8000
Starting metrics server on port: 2121
Handlers just return data or an error
Every handler has the same shape, func(c *gofr.Context) (any, error). The database is right there on the context:
go
func listInstructors(c *gofr.Context) (any, error) {
rows, err := c.SQL.QueryContext(c, "SELECT id, name, specialty FROM instructors ORDER BY id")
...
return instructors, rows.Err()
}
GoFr wraps whatever you return in a consistent envelope:
json
{"data":[{"id":1,"name":"Arun","specialty":"Beginners"}, ...]}
The part I liked most: typed errors become status codes
I never set a status code by hand. I return one of GoFr's error types and it picks the right HTTP status and message:
go
if !validSlots[b.Slot] {
return nil, gofrHTTP.ErrorInvalidParam{Params: []string{"slot"}} // 400
}
...
return nil, gofrHTTP.ErrorEntityNotFound{Name: "instructor_id", Value: "99"} // 404
...
return nil, gofrHTTP.ErrorEntityAlreadyExist{} // 409
A successful POST returns 201 Created and a DELETE returns 204 No Content automatically.
Here's the real output from the smoke test that runs in CI on every push:
text
[200] GET /.well-known/health -> {"data":{"name":"surf-api","status":"UP"}}
[201] POST /bookings -> {"data":{"id":1,"guest_name":"Priya","instructor_id":1,"date":"2026-10-05","slot":"morning"}}
[409] POST /bookings -> {"error":{"message":"entity already exists"}}
[400] POST /bookings -> {"error":{"message":"'1' invalid parameter(s): slot"}}
[400] POST /bookings -> {"error":{"message":"'3' missing parameter(s): instructor_id, date, slot"}}
[404] POST /bookings -> {"error":{"message":"No entity found with instructor_id: 99"}}
[204] DELETE /bookings/1 ->
[404] DELETE /bookings/1 -> {"error":{"message":"No entity found with id: 1"}}
All 12 checks passed
The health endpoint (/.well-known/health) comes built in; I didn't write it.
Observability for free
Every request is logged as structured JSON with a trace ID and response time, without me adding any middleware:
json
{"level":"INFO","message":{"trace_id":"f996d034...","method":"POST","uri":"/bookings","response":201,"response_time":1736}}
When the double-booking check fires, the warning and the request log share a trace ID, so it's easy to tie the two together. There's also a Prometheus metrics server on port 2121 out of the box.
Things that weren't perfect
Unique-constraint errors aren't typed. To turn "instructor already booked" into a 409, I had to check the SQLite error text for "unique". A typed "constraint violation" error would be nicer.
The error messages are a bit robotic. '1' invalid parameter(s): slot is correct, but I'd tidy it up before showing it to a guest in an app.
It needs a recent Go. The current GoFr release needs Go 1.26, so check your toolchain first.
Telemetry is on by default. GoFr logs that it "records the number of active servers" and tells you to set GOFR_TELEMETRY=false to turn it off. I'd have preferred opt-in, but at least it's upfront about it.
A bug that was mine, not GoFr's: I first sorted bookings by slot name, which put "evening" before "morning". My CI smoke test caught it.
Would I use it again?
For a small CRUD service like this, GoFr removed almost all of the boilerplate I'd normally write in Go: config, DB connection, migrations, logging, health checks and status codes. I spent my time on the actual booking rules instead.
Code: https://github.com/ajassaif/surf-api · GoFr: https://gofr.dev
Top comments (0)