DEV Community

Danushka Herath
Danushka Herath

Posted on

I wanted Spring Boot's ergonomics without leaving Go

Every Gin service I started ended up with the same dozen lines in each handler. Bind the body, check the error, write a 400 in whatever error shape this service uses, call the store, check for the one error that deserves a special status, write a 500 for the rest, and finally write the success.

Here is that handler for a small bookmarks API, written the honest Gin way:

type CreateBookmark struct {
    URL   string `json:"url" binding:"required,url"`
    Title string `json:"title" binding:"required"`
}

func createBookmark(c *gin.Context) {
    var req CreateBookmark
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error_code": "BAD_REQUEST", "message": err.Error()})
        return
    }
    b, err := store.Create(c.Request.Context(), req)
    if errors.Is(err, ErrDuplicate) {
        c.JSON(http.StatusConflict, gin.H{"error_code": "409", "message": "already bookmarked"})
        return
    }
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error_code": "500", "message": "internal error"})
        return
    }
    c.JSON(http.StatusCreated, b)
}
Enter fullscreen mode Exit fullscreen mode

Nothing is wrong with this. Gin is fast, small and well understood, and this code does exactly what it says. The problem is that it says the same thing in every handler, and each copy is a chance to forget a return after writing an error, or to invent a slightly different error envelope in the next service over.

Around it sits a second layer of repetition: a repository per collection with the same CRUD methods, config loading, a .env reader for local runs, a health endpoint, and an if lambda { ... } else { ... } in main. None of it is hard. All of it gets rewritten.

ginboot is my attempt to turn that layer into defaults while keeping Gin underneath. It is a set of opinions, not a new HTTP stack.

The handler returns, it doesn't write

The same endpoint in ginboot, as part of a controller:

type Bookmark struct {
    ID    string `bson:"_id" json:"id" ginboot:"id"`
    URL   string `bson:"url" json:"url"`
    Title string `bson:"title" json:"title"`
}

type CreateBookmark struct {
    URL   string `json:"url" binding:"required,url"`
    Title string `json:"title" binding:"required"`
}

var (
    ErrNotFound  = ginboot.NewApiError(404, "bookmark %s not found")
    ErrDuplicate = ginboot.NewApiError(409, "%s is already bookmarked")
)

type BookmarkController struct {
    repo ginboot.GenericRepository[Bookmark]
}

func (bc *BookmarkController) Register(g *ginboot.ControllerGroup) {
    g.GET("/:id", bc.get)
    g.POST("", bc.create)
}

func (bc *BookmarkController) get(ctx *ginboot.Context) (Bookmark, error) {
    id := ctx.Param("id")
    b, err := bc.repo.FindById(id)
    if errors.Is(err, mongodriver.ErrNoDocuments) {
        return Bookmark{}, ErrNotFound.New(id)
    }
    return b, err
}

func (bc *BookmarkController) create(req CreateBookmark) (Bookmark, error) {
    taken, err := bc.repo.ExistsBy("url", req.URL)
    if err != nil {
        return Bookmark{}, err
    }
    if taken {
        return Bookmark{}, ErrDuplicate.New(req.URL)
    }
    b := Bookmark{ID: primitive.NewObjectID().Hex(), URL: req.URL, Title: req.Title}
    return b, bc.repo.Save(b)
}
Enter fullscreen mode Exit fullscreen mode

Handlers return (T, error). ginboot looks at the function signature once, when the route is registered, and works out what to pass in. create asks for a CreateBookmark, so the body is bound (Gin's own ShouldBind and validator tags) before the function runs. If binding fails, the function is never called. get asks for *ginboot.Context, which embeds *gin.Context, so ctx.Param and everything else from Gin is still there. A handler can take nothing, a request, the context, or the context and a request.

What comes back is turned into a response in one place. A value becomes JSON with a 200. An ApiError becomes its status code and a fixed envelope. Any other error becomes a 500. So a client gets this:

$ curl -s -X POST localhost:8080/api/v1/bookmarks \
    -H 'Content-Type: application/json' -d '{"title":"The Go spec"}'
{"error_code":"BAD_REQUEST","message":"bad request: Key: 'CreateBookmark.URL' Error:Field validation for 'URL' failed on the 'required' tag"}

$ curl -s localhost:8080/api/v1/bookmarks/nope
{"error_code":"404","message":"bookmark nope not found"}
Enter fullscreen mode Exit fullscreen mode

The first is a 400, the second a 404. Every endpoint in every service built this way produces the same two keys, so the frontend has one error parser, not one per team.

One repository interface, wired in main

GenericRepository[T] is a Go generics interface with the methods I kept writing by hand: FindById, FindBy, FindByFilters, ExistsBy, CountBy, Save, SaveAll, Update, Delete, plus FindAllPaginated and FindByPaginated. The Mongo and SQL (GORM) implementations live in their own Go modules, so a service imports only the backend it uses.

Here is the whole main:

package main

import (
    "context"
    "log"
    "os"

    "github.com/klass-lk/ginboot"
    "github.com/klass-lk/ginboot/db/mongo"
    lambdarunner "github.com/klass-lk/ginboot/runtime/lambda"
    mongodriver "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
)

func main() {
    app := ginboot.New() // loads ginboot.yml and .env

    client, err := mongodriver.Connect(context.Background(),
        options.Client().ApplyURI(app.Config().Ginboot.DB.URL))
    if err != nil {
        log.Fatal(err)
    }
    repo := mongo.NewMongoRepository[Bookmark](client.Database("bookmarks"), "bookmarks")

    app.SetBasePath("/api/v1")
    app.RegisterController("/bookmarks", &BookmarkController{repo: repo})

    if os.Getenv("AWS_LAMBDA_RUNTIME_API") != "" {
        app.SetRunner(lambdarunner.NewRunnerFor(app))
    }
    log.Fatal(app.Start(8080))
}
Enter fullscreen mode Exit fullscreen mode

There is no container and no component scan. The controller gets its repository through a struct literal, the way you would wire anything else in Go. I took the parts of Spring Boot I missed (conventions, config, a consistent request lifecycle) and left out the dependency injection container.

ginboot.New() reads .env, .env.local and .env.development if they exist (without overwriting variables already set), then looks for ginboot.yml. The file can pull from the environment with defaults:

ginboot:
  db:
    url: ${DATABASE_URL:mongodb://localhost:27017}
Enter fullscreen mode Exit fullscreen mode

DATABASE_URL also overrides db.url directly, so a deployment can set one variable and ignore the file.

The last if is the Lambda switch. With the runner set, Start hands the Gin engine to the Lambda runtime, which accepts API Gateway v1 and v2 events (plus scheduled events and SQS batches if you registered workers or consumers). Without it, you get an HTTP server on 8080. Same controllers, same binary.

Two routes you didn't write also show up: /health and /healthz (and the same under the base path), and /openapi.json, built from the handler signatures as routes are registered. The spec is public by default because that is what you want locally. Set openapi.access to token or disabled before it goes anywhere else.

Telemetry follows the same rule of paying only for what you import. Add a blank import of github.com/klass-lk/ginboot/telemetry and enable it in ginboot.yml (or set OTEL_EXPORTER_OTLP_ENDPOINT), and requests get traces and metrics. If you ask for telemetry without the import, ginboot prints the exact import line you are missing instead of silently doing nothing.

What it costs you

I would rather you hear these from me.

The handler signature is checked with reflection at registration, not by the compiler. Register a function returning three values and the app panics at startup. You find out immediately, but you find out at runtime.

A returned value is always a 200. If create should answer 201, the handler has to write the response itself (ctx.JSON(201, b)) and ginboot notices the response is already written and stays out of the way. That works, but it is back to writing.

A plain error becomes a 500 with err.Error() as the message. If a database error's text is something you don't want a client to read, wrap it in an ApiError before returning it.

The repository abstraction leaks at the edges, and I haven't hidden that. Look at get above: it checks mongodriver.ErrNoDocuments, because FindById returns the driver's own not-found error and the SQL implementation returns GORM's. The DynamoDB repository needs a partition key on its lookups, so its method signatures differ and it does not satisfy GenericRepository[T] at all. You still get CRUD for free there, but it is not a drop-in swap.

And it is opinionated. If your team is happy with plain net/http and the Go 1.22 routing patterns, ginboot adds dependencies and conventions you did not ask for. If you want a different error envelope, you are writing your own SendError, not configuring this one.

Where Gin is still underneath

Middleware is still gin.HandlerFunc, passed to GET, POST or Group exactly as you would in Gin. func(*gin.Context) handlers are still accepted, unchanged. app.Engine() returns the *gin.Engine if you need something ginboot doesn't wrap. That was the constraint I set myself at the start: anything you know about Gin should keep working, and you should be able to move one route group at a time.

What you get for the trade is a handler that is mostly the business logic, one error format across services, and a main that runs on your laptop and on Lambda without a second entry point. For the kind of API services I build, that has been worth a reflection call per request.


ginboot is open source: github.com/klass-lk/ginboot. Docs are at ginboot.com, you can scaffold a project at start.ginboot.com, and Ginboot Cloud deploys the same binary to AWS Lambda from a GitHub repo.

Top comments (0)