<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ajassaif</title>
    <description>The latest articles on DEV Community by ajassaif (@ajassaif).</description>
    <link>https://dev.to/ajassaif</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4152415%2Fe95aff36-561f-4aac-8b73-21c6afe92fc1.jpg</url>
      <title>DEV Community: ajassaif</title>
      <link>https://dev.to/ajassaif</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ajassaif"/>
    <language>en</language>
    <item>
      <title>Building a surf lesson booking API with GoFr: what surprised me</title>
      <dc:creator>ajassaif</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:43:43 +0000</pubDate>
      <link>https://dev.to/ajassaif/building-a-surf-lesson-booking-api-with-gofr-what-surprised-me-10k5</link>
      <guid>https://dev.to/ajassaif/building-a-surf-lesson-booking-api-with-gofr-what-surprised-me-10k5</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The goal was simple:&lt;/p&gt;

&lt;p&gt;list our instructors&lt;br&gt;
book a lesson for a date and a slot (morning or evening, because that's when the waves are good)&lt;br&gt;
stop two guests from booking the same instructor for the same slot&lt;br&gt;
cancel a booking&lt;/p&gt;

&lt;p&gt;All the code is here: &lt;a href="https://github.com/ajassaif/surf-api" rel="noopener noreferrer"&gt;https://github.com/ajassaif/surf-api&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The whole app fits in one screen&lt;/p&gt;

&lt;p&gt;This is the entire main:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
func main() {&lt;br&gt;
    app := gofr.New()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.Migrate(migrations.All())

app.GET("/instructors", listInstructors)
app.GET("/bookings", listBookings)
app.POST("/bookings", createBooking)
app.DELETE("/bookings/{id}", cancelBooking)

app.Run()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;There's no router setup, no DB connection code and no logger wiring. GoFr reads configs/.env:&lt;/p&gt;

&lt;p&gt;dotenv&lt;br&gt;
APP_NAME=surf-api&lt;br&gt;
HTTP_PORT=8000&lt;br&gt;
DB_DIALECT=sqlite&lt;br&gt;
DB_NAME=surf.db&lt;/p&gt;

&lt;p&gt;…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:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
Loaded config from file: ./configs/.env&lt;br&gt;
connected to 'surf.db' database&lt;br&gt;
running migration 20260930180000&lt;br&gt;
Migration 20260930180000 ran successfully&lt;br&gt;
Starting server on port: 8000&lt;br&gt;
Starting metrics server on port: 2121&lt;br&gt;
Handlers just return data or an error&lt;/p&gt;

&lt;p&gt;Every handler has the same shape, func(c *gofr.Context) (any, error). The database is right there on the context:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
func listInstructors(c *gofr.Context) (any, error) {&lt;br&gt;
    rows, err := c.SQL.QueryContext(c, "SELECT id, name, specialty FROM instructors ORDER BY id")&lt;br&gt;
    ...&lt;br&gt;
    return instructors, rows.Err()&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;GoFr wraps whatever you return in a consistent envelope:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{"data":[{"id":1,"name":"Arun","specialty":"Beginners"}, ...]}&lt;br&gt;
The part I liked most: typed errors become status codes&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;go&lt;br&gt;
if !validSlots[b.Slot] {&lt;br&gt;
    return nil, gofrHTTP.ErrorInvalidParam{Params: []string{"slot"}}   // 400&lt;br&gt;
}&lt;br&gt;
...&lt;br&gt;
return nil, gofrHTTP.ErrorEntityNotFound{Name: "instructor_id", Value: "99"} // 404&lt;br&gt;
...&lt;br&gt;
return nil, gofrHTTP.ErrorEntityAlreadyExist{}                       // 409&lt;/p&gt;

&lt;p&gt;A successful POST returns 201 Created and a DELETE returns 204 No Content automatically.&lt;/p&gt;

&lt;p&gt;Here's the real output from the smoke test that runs in CI on every push:&lt;/p&gt;

&lt;p&gt;text&lt;br&gt;
[200] GET /.well-known/health -&amp;gt; {"data":{"name":"surf-api","status":"UP"}}&lt;br&gt;
[201] POST /bookings -&amp;gt; {"data":{"id":1,"guest_name":"Priya","instructor_id":1,"date":"2026-10-05","slot":"morning"}}&lt;br&gt;
[409] POST /bookings -&amp;gt; {"error":{"message":"entity already exists"}}&lt;br&gt;
[400] POST /bookings -&amp;gt; {"error":{"message":"'1' invalid parameter(s): slot"}}&lt;br&gt;
[400] POST /bookings -&amp;gt; {"error":{"message":"'3' missing parameter(s): instructor_id, date, slot"}}&lt;br&gt;
[404] POST /bookings -&amp;gt; {"error":{"message":"No entity found with instructor_id: 99"}}&lt;br&gt;
[204] DELETE /bookings/1 -&amp;gt;&lt;br&gt;
[404] DELETE /bookings/1 -&amp;gt; {"error":{"message":"No entity found with id: 1"}}&lt;br&gt;
All 12 checks passed&lt;/p&gt;

&lt;p&gt;The health endpoint (/.well-known/health) comes built in; I didn't write it.&lt;/p&gt;

&lt;p&gt;Observability for free&lt;/p&gt;

&lt;p&gt;Every request is logged as structured JSON with a trace ID and response time, without me adding any middleware:&lt;/p&gt;

&lt;p&gt;json&lt;br&gt;
{"level":"INFO","message":{"trace_id":"f996d034...","method":"POST","uri":"/bookings","response":201,"response_time":1736}}&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Things that weren't perfect&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
It needs a recent Go. The current GoFr release needs Go 1.26, so check your toolchain first.&lt;br&gt;
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.&lt;br&gt;
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.&lt;br&gt;
Would I use it again?&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/ajassaif/surf-api" rel="noopener noreferrer"&gt;https://github.com/ajassaif/surf-api&lt;/a&gt; · GoFr: &lt;a href="https://gofr.dev" rel="noopener noreferrer"&gt;https://gofr.dev&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>api</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
