Migration

Migrating an Existing Go App

Move a running Go API onto Ginboot without a rewrite — from Gin, net/http, Echo, Fiber, Chi or gorilla/mux — one endpoint group at a time.

This guide moves an existing Go HTTP service onto Ginboot incrementally. The application builds, starts and serves traffic after every step. There is no branch that is broken for three weeks.

Why this can be incremental

Ginboot is a layer over Gin, and it exposes the engine rather than hiding it. Two properties make a gradual migration possible:

  1. server.Engine() returns the real *gin.Engine. Anything you can do with Gin, you can still do — including registering your entire existing route tree in one line.
  2. Ginboot route groups accept Gin's own handler signature. A func(c *gin.Context) handler registered through group.GET is passed through untouched, so a controller can hold converted and unconverted handlers at the same time.

If you are not on Gin

Everything below still applies, but handlers that take an echo.Context, a *fiber.Ctx or a http.ResponseWriter/*http.Request pair cannot be passed through — those are different types. See Coming from another router for what changes and what carries over.


Step 1 — Install Ginboot and take over the entrypoint

go get -u github.com/klass-lk/ginboot

Replace the code that builds and runs your router. Keep everything else.

// Before
func main() {
	r := gin.Default()
	r.Use(middleware.RequestID())
	registerRoutes(r) // your existing route tree
	log.Fatal(r.Run(":8080"))
}

// After
func main() {
	server := ginboot.New()

	// Server-wide middleware goes on the engine.
	server.Engine().Use(middleware.RequestID())

	// Every existing route, registered exactly as before.
	registerRoutes(server.Engine())

	log.Fatal(server.Start(8080))
}

At this point nothing has changed for your clients, and you have gained automatic .env and ginboot.yml loading, /healthz, and the ability to add controllers alongside the old routes.

There is no server.Use

Server-wide middleware is applied with server.Engine().Use(...). Middleware applies only to routes registered after it, so put these calls before your controllers.

Verify before moving on:

go build ./... && go run . &
curl -s localhost:8080/healthz          # {"status":"UP",...}
curl -s localhost:8080/your/old/route   # unchanged response

Step 2 — Move configuration into ginboot.yml

ginboot.New() already loaded .env, .env.local and .env.development, then the first of ginboot.yml, application.yml, ginboot.yaml or application.yaml that it found. Create the file with the variable names you already deploy with:

ginboot:
  server:
    port: ${PORT:8080}
    base-path: /api/v1
    env: ${ENV:development}

  db:
    driver: postgres
    url: ${DATABASE_URL:postgres://postgres:secret@localhost:5432/app_db}

Then read it back instead of reaching for os.Getenv in scattered places:

server := ginboot.New()
cfg := server.Config()

server.SetBasePath(cfg.Ginboot.Server.BasePath)
log.Fatal(server.Start(cfg.Ginboot.Server.Port))

Values already present in the real environment always win over the defaults in the file, so a platform-injected production secret is never overridden by a committed fallback. See Configuration for the full key list.

Watch the base path

SetBasePath("/api/v1") prefixes every route registered through Ginboot. If your existing route strings already contain /api/v1, you will end up with /api/v1/api/v1/.... Strip the prefix from the route strings, or leave the base path empty until the conversion is done.


Step 3 — Convert endpoints into controllers

This is the bulk of the work, done one resource at a time.

The shape of a controller

A controller is any type with a Register method. Ginboot recommends registering routes only inside controllers — not directly on the engine — once the migration is complete.

type UserController struct {
	users *service.UserService
}

func NewUserController(users *service.UserService) *UserController {
	return &UserController{users: users}
}

func (c *UserController) Register(group *ginboot.ControllerGroup) {
	group.GET("", c.List)
	group.GET("/:id", c.Get)

	protected := group.Group("", middleware.Auth())
	{
		protected.POST("", c.Create)
		protected.PUT("/:id", c.Update)
		protected.DELETE("/:id", c.Delete)
	}
}
server.RegisterController("/users", userController) // → /api/v1/users/...

Translating handlers

A Ginboot handler takes what it needs and returns (value, error). The framework binds the request, serialises the response and maps the error.

Your handler todayGinboot signature
Reads path/query params, no bodyfunc(ctx *ginboot.Context) (T, error)
Binds a JSON body, nothing elsefunc(req R) (T, error)
Binds a body and needs params or authfunc(ctx *ginboot.Context, req R) (T, error)
Takes no input at allfunc() (T, error)
Not converted yetfunc(c *gin.Context) — accepted unchanged
// Before
func (c *UserController) Get(ctx *gin.Context) {
	user, err := c.users.FindById(ctx.Param("id"))
	if err != nil {
		ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
		return
	}
	ctx.JSON(http.StatusOK, user)
}

// After
func (c *UserController) Get(ctx *ginboot.Context) (model.User, error) {
	return c.users.FindById(ctx.Param("id"))
}

The 404 does not disappear — it moves into the service, which returns ErrUserNotFound.New(id). See Step 4.

Success is always 200

On the success path Ginboot writes 200 OK — a string return as text/plain, anything else as JSON, and nil as a bare 200. If your contract has 201 Created or 204 No Content, write the response yourself and return nil, nil. Ginboot checks whether the response has already been written and leaves it alone:

func (c *UserController) Create(ctx *ginboot.Context, req CreateUserRequest) (any, error) {
	user, err := c.users.Create(req)
	if err != nil {
		return nil, err
	}
	ctx.JSON(http.StatusCreated, user) // written here, not overridden
	return nil, nil
}

For an endpoint that genuinely returns nothing, return ginboot.EmptyResponse{}.

Middleware and the auth context

Gin middleware keeps working unchanged, at three levels:

server.Engine().Use(middleware.RequestID())            // server-wide
protected := group.Group("/admin", middleware.Auth())  // group
group.GET("/users", c.List, middleware.Cache())        // single route

ctx.GetAuthContext() reads two specific keys out of the Gin context, so your existing auth middleware needs to set exactly these:

c.Set("user_id", claims.Subject)
c.Set("role", claims.Role)

With those set, handlers get a typed context and a 401 is raised automatically when either key is missing:

auth, err := ctx.GetAuthContext()
if err != nil {
	return nil, err
}
_ = auth.UserID
_ = auth.Roles

Step 4 — Convert errors to ApiError

Stop formatting error responses in handlers. Declare the API's errors once, return them from services, and let the framework map them:

var (
	ErrUserNotFound = ginboot.NewApiError(404, "User with ID %s not found")
	ErrEmailTaken   = ginboot.NewApiError(409, "Email %s is already registered")
)

func (s *UserService) FindById(id string) (model.User, error) {
	user, err := s.repo.FindById(id)
	if err != nil {
		return model.User{}, ErrUserNotFound.New(id)
	}
	return user, nil
}

The client sees the status code carried by the error and a consistent body:

{ "error_code": "404", "message": "User with ID 42 not found" }

Any error that is not an ApiError becomes a 500, so an unmapped internal failure can never leak as a 200. Errors returned from ctx.CallService are, by default, reported to your caller as 502 UPSTREAM_ERROR rather than forwarded — see Inter-Service Communication.

Keep the codes you already publish

Clients depend on your current status codes. Map each existing error response to an ApiError with the same code before you delete the old handler branch, and diff a few real error responses against the old service.


Step 5 — Move the data access layer

Ginboot's repositories live in their own Go modules, so you only pull in the driver you actually use:

BackendModuleConstructor
MongoDBgithub.com/klass-lk/ginboot/db/mongomongo.NewMongoRepository[T](db, "collection")
SQL (GORM)github.com/klass-lk/ginboot/db/sqlsql.NewSQLRepository[T](db)
DynamoDBgithub.com/klass-lk/ginboot/db/dynamodbdynamodb.NewDynamoDBRepository[T](client)
In-memorygithub.com/klass-lk/ginboot/db/inmemoryinmemory.NewInMemoryRepository[T]()
go get github.com/klass-lk/ginboot/db/mongo

Embed the repository to keep your custom queries next to the generated CRUD:

import dbMongo "github.com/klass-lk/ginboot/db/mongo"

type UserRepository struct {
	*dbMongo.MongoRepository[model.User]
}

func NewUserRepository(db *mongo.Database) *UserRepository {
	return &UserRepository{
		MongoRepository: dbMongo.NewMongoRepository[model.User](db, "users"),
	}
}

// Your existing hand-written query, kept as-is.
func (r *UserRepository) FindActiveByTenant(tenantID string) ([]model.User, error) {
	return r.FindByFilters(map[string]interface{}{"tenant_id": tenantID, "status": "active"})
}

Your models need tags for the backend (bson, db/GORM, dynamodbav) and, where the primary key is not obvious, a ginboot:"_id" or ginboot:"id" tag. SQL and DynamoDB models must implement GetTableName() string. The full interface — FindById, FindBy, FindByFilters, FindAllPaginated, CountBy, ExistsBy and the rest — is in Database Support.

Schema migrations stay yours

Ginboot does not run schema migrations. Keep golang-migrate, Atlas, Flyway or whatever you use today — nothing about it changes.

If swapping the data layer and the routing layer at once feels risky, keep your existing repository type and give it the methods your controllers call. The repository interface is a convenience, not a requirement.


Step 6 — Move background work

What you haveGinboot equivalent
A goroutine with a time.Tickerserver.RegisterWorker("name", 5*time.Minute, fn)
A robfig/cron jobA type implementing Worker plus Cron() string, registered with RegisterWorkerStruct
An SQS/queue pollerserver.RegisterConsumer(ginboot.NewQueueConsumer("sms", ginboot.Queue("sms"), fn))
server.RegisterWorker("cleanup", time.Hour, func(ctx context.Context) error {
	return svc.PurgeExpiredSessions(ctx)
})

Workers registered this way run on a server and on AWS Lambda, which a hand-rolled goroutine does not. See Background Workers and Event Triggers.


Step 7 — Turn on the platform features

Each of these is independent and additive:

  • Telemetry — go get github.com/klass-lk/ginboot/telemetry, import it blank, and set telemetry.enabled: true. Replace log.Printf with ctx.Logger().Info(...) to get trace-correlated logs. See Telemetry.

  • OpenAPI — the spec is generated from your controllers' types. Export it with GINBOOT_EXPORT_SWAGGER=openapi.json go run ., and diff it against the contract your clients hold. See OpenAPI & Swagger.

  • Caching — response caching with tag invalidation over DynamoDB, SQL or MongoDB. See Caching.

  • AWS Lambda — the same controllers behind API Gateway:

    import lambdarunner "github.com/klass-lk/ginboot/runtime/lambda"
    
    if os.Getenv("LAMBDA_TASK_ROOT") != "" {
    	server.SetRunner(lambdarunner.NewRunnerFor(server))
    }

    NewRunnerFor wires the scheduler and the consumers too, which the older NewRunner() does not. See AWS Lambda Support.


Coming from another router

The phases are the same. What differs is that handlers must be rewritten rather than passed through, because the context type is different.

FrameworkMountable during migrationHandler conversion
GinYes — same engineOptional, and per route
net/http, Chi, gorilla/muxYes, via gin.WrapH(w, r) → (ctx *ginboot.Context) (T, error); r.URL.Query().Get → ctx.Query; mux.Vars(r)["id"] → ctx.Param("id")
EchoYes, via gin.WrapHc.Bind(&req) → a request parameter; c.JSON(200, v) → return v, nil; echo.NewHTTPError(404, msg) → ginboot.NewApiError(404, msg)
FiberNo — fasthttp, not net/httpAs above, plus run both processes side by side during the cutover

What carries over untouched in every case: your services, domain models, validation rules, SQL, tests for business logic, and your deployment pipeline.


Migration checklist

Work down this list per endpoint group; it is the same list the agent playbook uses.

  • ginboot.New() owns the entrypoint and the app starts.
  • Settings come from ginboot.yml / environment via server.Config().
  • Each resource has a controller registered with server.RegisterController.
  • Handlers return (T, error) and do not write responses — except where a non-200 status is deliberate.
  • Request structs carry json and binding tags; no manual ShouldBindJSON in handlers.
  • Every error path returns a ginboot.ApiError with the original status code.
  • Auth middleware sets user_id and role; handlers use ctx.GetAuthContext().
  • Repositories replace hand-written CRUD, or the existing ones are wired in.
  • Background jobs are registered workers or consumers.
  • The exported OpenAPI spec matches the published contract.
  • The old router, binding, error-formatting and config code is deleted.

Common pitfalls

SymptomCauseFix
panic: handler must return (response, error)A converted handler returns one value or threeReturn exactly two values, the second an error
panic: first argument must be *Context when using two argumentsTwo-argument handler with the request firstOrder is always (ctx *ginboot.Context, req R)
panic: handler must have 0-2 argumentsExtra parameters on the handlerRead anything else off ctx
Routes answer at /api/v1/api/v1/...Base path set and baked into route stringsRemove the prefix from the route strings
Headers were already written in the logHandler wrote a response and returned a valueReturn nil, nil after writing yourself
Every response is 200, contract says 201Ginboot's success defaultWrite the status explicitly, return nil, nil
401 on every protected routeMiddleware does not set user_id and roleSet both keys with c.Set
Middleware never runsRegistered after the routesserver.Engine().Use(...) before registering controllers

Next steps

On this page