← Back to list

Go Doesn’t Need Rails — It Has AddRESTHandlers

How GoFr uses reflection to auto-generate a full REST API from a single Go struct, and why this changes how you think about backend…

Naman · 2026-05-08 05:16 · 0 claps · 11.4 min read
#golang #gofr #microservices-application #rest-api #backend-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Go Doesn’t Need Rails — It Has AddRESTHandlers

How GoFr uses reflection to auto-generate a full REST API from a single Go struct, and why this changes how you think about backend development

By Naman

Go developers have a reputation. We write everything explicitly. We distrust magic. We scoff at frameworks that “hide too much.” Ask a room full of Go engineers what they think of code generation and you’ll get a lecture about how simplicity is the language’s greatest virtue.

So when I first saw this:

app.AddRESTHandlers(&user{})

…and realized it had just registered five fully functional HTTP routes, connected to a real database, with parameterized queries, I felt genuinely uncomfortable. No cap.

Then I read the source code. And I realized it’s not magic at all — it’s reflection, done right. It lowkey understood the assignment. This article breaks down exactly how GoFr’s AddRESTHandlers works under the hood, when you should use it, and when you should reach for something else.

What Is GoFr?

GoFr is an open-source Go framework for building microservices. Think of it as the opinionated layer you’d otherwise spend two weeks wiring up yourself: structured logging, OpenTelemetry tracing, Prometheus metrics, database connection pooling, pub/sub, gRPC, WebSocket — all configured through environment variables, all flowing through a single *gofr.Context into your handlers.

But the feature that stands out — and the one no article has covered in depth — is AddRESTHandlers. It's the closest thing Go has to ActiveRecord or Django's ModelViewSet, built without a code generator, without decorators, and without a separate DSL. This feature is genuinely built different.

The One-Liner That Registers Five Routes

Here’s the minimal working example, adapted directly from GoFr’s own examples:

package main
import (
    "gofr.dev/examples/using-add-rest-handlers/migrations"
    "gofr.dev/pkg/gofr"
)
type user struct {
    Id         int    `json:"id"`
    Name       string `json:"name"`
    Age        int    `json:"age"`
    IsEmployed bool   `json:"isEmployed"`
}
func main() {
    app := gofr.New()
    // Run database migrations first
    app.Migrate(migrations.All())
    // This single call registers all 5 CRUD routes
    if err := app.AddRESTHandlers(&user{}); err != nil {
        return
    }
    app.Run()
}

That’s it. No handler functions. No SQL. No route registration. Bro really said “hold my beer” to boilerplate. After app.Run(), you have:

MethodRouteOperationPOST/userCreate a userGET/userGet all usersGET/user/{id}Get user by IDPUT/user/{id}Update user by IDDELETE/user/{id}Delete user by ID

Every one of these talks to the database you’ve configured via environment variables. Every one is traced with OpenTelemetry, emits Prometheus metrics, and logs structured JSON — automatically, because all GoFr handlers get those for free. It’s giving full-stack framework energy in a language that usually makes you do everything by hand.

What Actually Happens: The Reflection Pipeline

Let’s follow the code. When you call app.AddRESTHandlers(&user{}), it calls scanEntity, which is where the real work begins.

Step 1 — Validate the input

// pkg/gofr/crud_handlers.go
func scanEntity(object any) (*entity, error) {
    if object == nil {
        return nil, errObjectIsNil
    }
    objType := reflect.TypeOf(object)
    if objType.Kind() != reflect.Ptr {
        return nil, fmt.Errorf("failed to register routes for '%s' struct, %w",
            objType.Name(), errNonPointerObject)
    }
    entityType := objType.Elem()
    if entityType.Kind() != reflect.Struct {
        return nil, errInvalidObject
    }
    // ...
}

GoFr enforces that you pass a pointer to a struct — not a value, not an interface. This is a deliberate API decision. The pointer is required later so GoFr can call reflect.New(entityType) to allocate fresh instances of your struct for each request without any coupling to the original zero value you passed in.

Step 2 — Extract the primary key

primaryKeyField := entityValue.Field(0) // Assume the first field is the primary key
primaryKeyFieldName := toSnakeCase(primaryKeyField.Name)

The first field of your struct is always treated as the primary key. For user, that's Id → converted to id. This is a convention, not configuration — which is a very deliberate GoFr choice. It keeps the API surface tiny.

Step 3 — Derive table name and REST path

// pkg/gofr/crud_helpers.go
func getTableName(object any, structName string) string {
    if v, ok := object.(TableNameOverrider); ok {
        return v.TableName()
    }
    return toSnakeCase(structName)  // "user" → "user"
}
func getRestPath(object any, structName string) string {
    if v, ok := object.(RestPathOverrider); ok {
        return v.RestPath()
    }
    return strings.ToLower(structName)  // "user" → "user"
}

By default, both the SQL table name and the URL path are derived from the struct name by converting to snake_case. For a struct named BlogPost, the table is blog_post and the route is /blogpost.

But notice those interface checks: TableNameOverrider and RestPathOverrider. These are your escape hatches, and we'll use them in a moment.

Step 4 — Parse struct tags into constraints

for i := 0; i < entityType.NumField(); i++ {
    field := entityType.Field(i)
    fieldName := toSnakeCase(field.Name)
    constraints, err := parseSQLTag(field.Tag)
    if err != nil {
        return nil, err
    }
    e.constraints[fieldName] = constraints
}

GoFr reads its own sql struct tag — not db, not gorm, its own. Right now it supports two options:

type Product struct {
    Id    int    `json:"id"    sql:"auto_increment"`
    Name  string `json:"name"  sql:"not_null"`
    Price float64 `json:"price"`
}
  • auto_increment — tells GoFr to skip this field on INSERT and use LastInsertId() to return the generated key
  • not_null — GoFr validates this field before even hitting the database

Step 5 — Register the routes

// pkg/gofr/crud_handlers.go
func (a *App) registerCRUDHandlers(e *entity, object any) {
    basePath := fmt.Sprintf("/%s", e.restPath)
    idPath   := fmt.Sprintf("/%s/{%s}", e.restPath, e.primaryKey)
    if fn, ok := object.(Create); ok {
        a.POST(basePath, fn.Create)
    } else {
        a.POST(basePath, e.Create)
    }
    if fn, ok := object.(GetAll); ok {
        a.GET(basePath, fn.GetAll)
    } else {
        a.GET(basePath, e.GetAll)
    }
    if fn, ok := object.(Get); ok {
        a.GET(idPath, fn.Get)
    } else {
        a.GET(idPath, e.Get)
    }
    if fn, ok := object.(Update); ok {
        a.PUT(idPath, fn.Update)
    } else {
        a.PUT(idPath, e.Update)
    }
    if fn, ok := object.(Delete); ok {
        a.DELETE(idPath, fn.Delete)
    } else {
        a.DELETE(idPath, e.Delete)
    }
}

This is the most elegant part of the whole system. For each CRUD operation, GoFr does a runtime interface check. If your struct implements the matching interface, GoFr uses your method. If not, it falls back to its own generated handler. Slay.

This means you can override exactly the operations you need — without touching the ones you don’t. The framework doesn’t gatekeep your customization.

How the Generated Handlers Work

Let’s look at what actually happens at request time. Here’s e.Create:

func (e *entity) Create(c *Context) (any, error) {
    newEntity, err := e.bindAndValidateEntity(c)
    if err != nil {
        return nil, err
    }
    fieldNames, fieldValues := e.extractFields(newEntity)
    stmt, err := sql.InsertQuery(c.SQL.Dialect(), e.tableName, fieldNames, fieldValues, e.constraints)
    if err != nil {
        return nil, err
    }
    result, err := c.SQL.ExecContext(c, stmt, fieldValues...)
    if err != nil {
        return nil, err
    }
    // ...
    return fmt.Sprintf("%s successfully created with id: %v", e.name, lastID), nil
}

A few things to notice:

  1. **c.SQL.Dialect()** — the INSERT query is built dynamically based on your database dialect. MySQL uses ? placeholders, PostgreSQL uses $1, $2. The query builder handles both.
  2. **e.bindAndValidateEntity** — it calls reflect.New(e.entityType) to get a fresh instance of your struct, then calls c.Bind(newEntity) which reads from the JSON request body. Validation runs immediately after binding, before any SQL.
  3. **e.extractFields** — iterates the struct fields, skips anything tagged auto_increment, and builds the column name / value slices for the query.

And the GetAll handler, which shows the scan approach for rows:

func (e *entity) GetAll(c *Context) (any, error) {
    query := sql.SelectQuery(c.SQL.Dialect(), e.tableName)
    rows, err := c.SQL.QueryContext(c, query)
    // ...
    dest := make([]any, e.entityType.NumField())
    val  := reflect.New(e.entityType).Elem()
    for i := 0; i < e.entityType.NumField(); i++ {
        dest[i] = val.Field(i).Addr().Interface()
    }
    var entities []any
    for rows.Next() {
        newEntity := reflect.New(e.entityType).Interface()
        newVal    := reflect.ValueOf(newEntity).Elem()
        err = rows.Scan(dest...)
        // ...
        for i := 0; i < e.entityType.NumField(); i++ {
            scanVal := reflect.ValueOf(dest[i]).Elem().Interface()
            newVal.Field(i).Set(reflect.ValueOf(scanVal))
        }
        entities = append(entities, newEntity)
    }
    return entities, nil
}

GoFr builds a dest []any slice where each element is the address of a field in a dynamically-allocated struct. rows.Scan(dest...) populates them. Then it copies the scanned values into a fresh newEntity instance per row. Pure reflection — no generics needed, works with any struct shape. This pattern goes hard, not gonna lie.

The toSnakeCase Converter

This small utility function is worth examining because it handles a non-trivial edge case:

// pkg/gofr/crud_helpers.go
func toSnakeCase(str string) string {
    diff := 'a' - 'A'
    length := len(str)
    var builder strings.Builder
    for i, char := range str {
        if char >= 'a' {
            builder.WriteRune(char)
            continue
        }
        if (i != 0 || i == length-1) &&
           ((i > 0 && rune(str[i-1]) >= 'a') || (i < length-1 && rune(str[i+1]) >= 'a')) {
            builder.WriteRune('_')
        }
        builder.WriteRune(char + diff)
    }
    return builder.String()
}

The condition (i != 0 || i == length-1) && (prev is lowercase || next is lowercase) ensures underscores are inserted at case boundaries — but not at the very start of the string and not for consecutive capitals (so HTTPServerhttp_server, not h_t_t_p_server).

UserIDuser_id BlogPostblog_post HTTPSClienthttps_client

Your Go struct field names map directly to your SQL column names. Name them accordingly. Rent free in your head from now on.

Overriding What You Need

This is where AddRESTHandlers stops being a blunt instrument and becomes a precision tool.

Override a specific route

You implemented GetAll to return something custom — GoFr uses it. The other four routes still use the generated handlers:

type user struct {
    Id         int    `json:"id"`
    Name       string `json:"name"`
    Age        int    `json:"age"`
    IsEmployed bool   `json:"isEmployed"`
}
// Override only GetAll — everything else is still generated
func (u *user) GetAll(c *gofr.Context) (any, error) {
    // Your custom logic: pagination, filtering, joins...
    return "custom GetAll response", nil
}

GoFr’s interface check if fn, ok := object.(GetAll); ok at registration time sees your method and wires it in instead. The other four operations — Create, Get, Update, Delete — remain fully generated.

Override the table name

type BlogPost struct {
    Id      int    `json:"id"`
    Title   string `json:"title"`
    Content string `json:"content"`
}
// Without this: table = "blog_post", route = "/blogpost"
// With this:    table = "posts",     route = "/blogpost"
func (b *BlogPost) TableName() string {
    return "posts"
}

Override the REST path

func (b *BlogPost) RestPath() string {
    return "blog-posts"  // route becomes /blog-posts and /blog-posts/{id}
}

Override both

func (b *BlogPost) TableName() string { return "posts" }
func (b *BlogPost) RestPath()   string { return "blog-posts" }

These two interfaces give you full control over naming without losing any of the generated behavior. Customize your fit without burning the whole drip.

The Escape Hatches Are Interfaces, Not Config

This design decision is worth calling out explicitly. GoFr could have made these overrides configuration:

// Hypothetical — NOT how GoFr works
app.AddRESTHandlers(&BlogPost{}, gofr.WithTableName("posts"), gofr.WithPath("blog-posts"))

Instead, it chose interfaces. This means:

  • Overrides are type-safe — the compiler enforces the method signature
  • Overrides are discoverable — grep for TableNameOverrider in your codebase and you instantly find every struct that customizes naming
  • Overrides are testable — you can call b.TableName() in a unit test without spinning up the framework
  • They compose naturally with the rest of Go — no special framework knowledge needed

The same pattern applies to per-operation overrides. GoFr defines five single-method interfaces:

type Create  interface { Create(c *Context)  (any, error) }
type GetAll  interface { GetAll(c *Context)  (any, error) }
type Get     interface { Get(c *Context)     (any, error) }
type Update  interface { Update(c *Context)  (any, error) }
type Delete  interface { Delete(c *Context)  (any, error) }

Implement any combination. Skip the rest. The system checks each one independently at registration time. Pick your battles, bestie.

What Gets Generated Under the Hood (The Full SQL)

For a user struct on MySQL, here's exactly what GoFr generates for each operation:

-- POST /user
INSERT INTO `user` (`name`, `age`, `is_employed`) VALUES (?, ?, ?)
-- GET /user
SELECT * FROM `user`
-- GET /user/{id}
SELECT * FROM `user` WHERE `id`=?
-- PUT /user/{id}
UPDATE `user` SET `name`=?, `age`=?, `is_employed`=? WHERE `id`=?
-- DELETE /user/{id}
DELETE FROM `user` WHERE `id`=?

On PostgreSQL, ? placeholders become $1, $2, $3. The query builder (InsertQuery, SelectQuery, SelectByQuery, UpdateByQuery, DeleteByQuery) abstracts over dialects — MySQL, PostgreSQL, SQLite, CockroachDB, Supabase all work without any change to your struct. One struct to rule them all, and honestly? That's the vibe.

A Complete Real-World Example

Let’s build a product catalog API with:

  • Auto-increment primary key
  • A custom table name (legacy schema)
  • Pagination on GetAll (custom override)
  • Everything else generated
package main
import (
    "gofr.dev/pkg/gofr"
)
type Product struct {
    Id       int     `json:"id"       sql:"auto_increment"`
    Name     string  `json:"name"     sql:"not_null"`
    Price    float64 `json:"price"    sql:"not_null"`
    Category string  `json:"category"`
}
// Legacy DB uses "products_v2" as table name
func (p *Product) TableName() string {
    return "products_v2"
}
// Override GetAll to add basic pagination
func (p *Product) GetAll(c *gofr.Context) (any, error) {
    limit := c.Request.Param("limit")
    if limit == "" {
        limit = "20"
    }
    offset := c.Request.Param("offset")
    if offset == "" {
        offset = "0"
    }
    var products []Product
    rows, err := c.SQL.QueryContext(c,
        "SELECT id, name, price, category FROM products_v2 LIMIT ? OFFSET ?",
        limit, offset,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    for rows.Next() {
        var product Product
        if err := rows.Scan(&product.Id, &product.Name, &product.Price, &product.Category); err != nil {
            return nil, err
        }
        products = append(products, product)
    }
    return products, nil
}
func main() {
    app := gofr.New()
    if err := app.AddRESTHandlers(&Product{}); err != nil {
        return
    }
    app.Run()
}

What you get:

  • GET /product → your paginated query
  • POST /product → generated INSERT (skips id, uses LastInsertId())
  • GET /product/{id} → generated SELECT by ID
  • PUT /product/{id} → generated UPDATE
  • DELETE /product/{id} → generated DELETE

Thirty-five lines of code. A full, production-wired REST API. If that’s not a main character moment, I don’t know what is.

The Honest Tradeoffs

AddRESTHandlers is not always the right tool. Here's when to use it and when to step away.

Use it when:

  • Your API closely mirrors your database schema
  • You’re prototyping and want to move fast
  • You have standard CRUD with light customization on 1–2 operations
  • You want to reduce the surface area of hand-written SQL that needs testing

Don’t use it when:

  • Your reads involve JOINs across multiple tables
  • You need field-level authorization (certain fields visible to certain roles)
  • Your write logic involves transactions across multiple tables
  • You have non-trivial business rules (e.g., stock checks before order creation)

In those cases, implement the relevant interface method and write the handler yourself. You don’t have to give up the entire feature — just override the specific operation that needs custom logic.

GoFr makes this decision per-operation, which is the key design insight. It’s not all-or-nothing. No need to be extra about it — just override what’s actually mid and let the rest cook.

Why This Doesn’t Feel Like Go — And Why It Still Is

The Go community’s skepticism toward “magic” is well-founded. Reflection can hide bugs, hurt performance, and make code hard to follow. So let’s address each concern directly.

“Reflection hides bugs.” The behavior is deterministic and documented. AddRESTHandlers always generates the same five routes for any struct. The field order determines the primary key. The first uppercase letter boundary determines column names. There's nothing hidden — it's a fixed algorithm that you can reason about completely once you've read scanEntity and registerCRUDHandlers.

“Reflection hurts performance.” The reflection happens once — at startup, during route registration. At request time, GoFr uses the pre-computed entity struct (which holds the reflect.Type, the table name, the primary key name, and the constraints map). reflect.New(e.entityType) at request time is fast — it's a heap allocation, not a type traversal.

“I can’t tell what SQL it’s running.” You can. Enable LOG_LEVEL=DEBUG and GoFr logs every query with its parameters. Or read query_builder.go — it's fewer than 120 lines and generates straightforward, readable SQL.

“What if I need to change behavior later?” Implement the interface. Your custom method takes over. No migration, no refactor of the rest of the routes.

The reflection here is constrained, purposeful, and escapable. That’s actually very Go. Understated and unbothered — a vibe the whole language runs on.

Setting It Up

You need a SQL database configured. GoFr reads from environment variables (or a configs/.env file):

APP_NAME=my-api
HTTP_PORT=8080
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=password
DB_NAME=mydb
DB_PORT=3306
DB_DIALECT=mysql   # or postgres, sqlite, cockroachdb, supabase

Install GoFr:

go get gofr.dev

Create your table (or use GoFr’s migration system):

CREATE TABLE IF NOT EXISTS user (
    id          INT         NOT NULL PRIMARY KEY,
    name        VARCHAR(50) NOT NULL,
    age         INT         NOT NULL,
    is_employed BOOL        NOT NULL
);

Run the app:

go run main.go

Hit it:

# Create
curl -X POST http://localhost:8080/user \
  -H "Content-Type: application/json" \
  -d '{"id":1,"name":"Naman","age":25,"isEmployed":true}'
# Get all
curl http://localhost:8080/user
# Get by ID
curl http://localhost:8080/user/1
# Update
curl -X PUT http://localhost:8080/user/1 \
  -H "Content-Type: application/json" \
  -d '{"id":1,"name":"Naman Aggarwal","age":26,"isEmployed":true}'
# Delete
curl -X DELETE http://localhost:8080/user/1

Summary

GoFr’s AddRESTHandlers is built on three layers:

  1. **scanEntity** — uses reflect.TypeOf at startup to extract struct shape, field names (converted to snake_case), primary key (first field), table name, REST path, and SQL constraints from struct tags
  2. **registerCRUDHandlers** — checks five single-method interfaces at registration time. If your struct implements one, your method is wired in. If not, the generated handler is used. This happens per operation, independently.
  3. Generated handlers — use reflect.New(entityType) per request to allocate a fresh struct, c.Bind() to decode JSON into it, and dialect-aware query builders to construct parameterized SQL. The same code works on MySQL, PostgreSQL, SQLite, and CockroachDB without any changes.

The result is a system that eliminates boilerplate where your logic is truly standard, while giving you clean, compiler-enforced escape hatches exactly where it isn’t.

Go doesn’t need Rails. But for the 80% of CRUD that is genuinely simple, it’s nice to have something that treats it that way. AddRESTHandlers said "I got you" — and it really does.

*GoFr is open source at github.com/gofr-dev/gofr.


메타데이터
post_id
7e36f22dbaf5
slug
go-doesnt-need-rails-it-has-addresthandlers-7e36f22dbaf5
url
https://medium.com/@thisis_naman/go-doesnt-need-rails-it-has-addresthandlers-7e36f22dbaf5
canonical_url
https://medium.com/@thisis_naman/go-doesnt-need-rails-it-has-addresthandlers-7e36f22dbaf5
author_url
https://medium.com/@thisis_naman
status
ok
fetched_at
2026-06-20 20:29:01