# Introduction (/docs)
Ginboot is a lightweight and powerful Go web framework built on top of Gin. It takes the Spring Boot approach — opinionated defaults, batteries included — and applies it to Go, so you can go from an empty directory to a production-ready API without assembling the plumbing yourself.
New here? The [Getting Started](/docs/1-getting-started) guide takes about five minutes, and
[start.ginboot.com](https://start.ginboot.com) will scaffold a project for you.
## Start here [#start-here]
## Why Ginboot [#why-ginboot]
* **Database agnostic** — a single `GenericRepository[T]` interface over MongoDB, SQL and DynamoDB, giving you CRUD and pagination with almost no code. See [Database Support](/docs/3-features/database).
* **Runs anywhere** — the same controllers serve HTTP locally and API Gateway events on AWS Lambda, with the runtime detected automatically. See [AWS Lambda Support](/docs/3-features/aws-lambda).
* **Declarative configuration** — `ginboot.yml` plus automatic `.env` loading and environment variable injection, read back through a typed `server.Config()`. See [Configuration](/docs/2-core-concepts/configuration).
* **Pluggable telemetry** — an optional OpenTelemetry plugin (`ginboot/telemetry`) ships traces, metrics and logs to Grafana or any OTLP backend without bloating the core. See [Telemetry](/docs/3-features/telemetry).
* **Context-bound logger** — `ctx.Logger().Info(...)` correlates every log line with the active distributed trace.
* **Service-to-service calls** — `ctx.CallService(...)` resolves targets from config, environment or DNS and propagates W3C trace and auth headers. See [Inter-Service Communication](/docs/3-features/service-communication).
# Getting Started (/docs/1-getting-started)
Ginboot is a robust Go web framework built on top of the popular Gin framework, designed to accelerate the development of production-ready microservices and APIs. It comes with built-in support for different databases, telemetry, and AWS Lambda deployments.
The fastest way to scaffold a new Ginboot project is our official generator at
[start.ginboot.com](https://start.ginboot.com) — the Go equivalent of `start.spring.io`.
### Open the generator [#open-the-generator]
Navigate to [start.ginboot.com](https://start.ginboot.com) and enter your project name.
### Pick your database [#pick-your-database]
Select the database you want wired up — PostgreSQL, MongoDB or MySQL. Ginboot generates the
matching repository setup for you.
### Toggle the features you need [#toggle-the-features-you-need]
Enable any extras up front: Telemetry, Jaeger tracing, Grafana dashboards.
### Generate and run [#generate-and-run]
Click **Generate Project** to download a ready-to-run `.zip`. Extract it, then:
```bash
go mod tidy
go run main.go
```
To add Ginboot to an existing Go module:
```bash
go get -u github.com/klass-lk/ginboot
```
### Basic example [#basic-example]
A minimal HTTP server:
```go
package main
import (
"log"
"github.com/klass-lk/ginboot"
)
func main() {
app := ginboot.New()
app.SetBasePath("/api/v1")
// Start the server on port 8080
if err := app.Start(8080); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
```
`ginboot.New()` also loads `ginboot.yml` and any `.env` file it finds, so most settings can move
out of code entirely. See [Configuration](/docs/2-core-concepts/configuration).
## Next steps [#next-steps]
# Configuration (/docs/2-core-concepts/configuration)
Ginboot provides a unified, declarative configuration system inspired by Spring Boot. Applications can be configured using a `ginboot.yml`, `application.yml`, or `ginboot.yaml` file alongside automatic `.env` environment file loading and live hot-reloading using Air.
***
## 1. Automatic `.env` Loading [#1-automatic-env-loading]
When `ginboot.New()` initializes, it automatically checks for and loads key-value pairs from local environment files in the current working directory in the following order:
Values from these files never override environment variables already set by your container or
host OS — so a production secret injected by the platform always wins over a committed default.
***
## 2. Declarative `ginboot.yml` / `application.yml` File [#2-declarative-ginbootyml--applicationyml-file]
Ginboot searches for configuration files automatically, using the first one it finds:
### Example `ginboot.yml` [#example-ginbootyml]
```yaml
ginboot:
server:
port: env(PORT, 8080)
base-path: /api/v1
env: ${ENV:development}
# Downstream Microservice Mappings
services:
user-service:
url: ${SERVICE_USER_SERVICE_URL:http://localhost:8081}
timeout: 5s
notification-service:
url: ${SERVICE_NOTIFICATION_SERVICE_URL:http://localhost:8082}
timeout: 10s
# Database Connection Configuration
db:
driver: postgres
url: ${DATABASE_URL:postgres://postgres:secret@localhost:5432/app_db}
max-open-conns: 25
# OpenTelemetry & Logging
telemetry:
enabled: true
service-name: order-service
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:}
```
These keys only do something when the application imports
`_ "github.com/klass-lk/ginboot/telemetry"`. See [Telemetry &
Observability](/docs/3-features/telemetry).
### Configuration keys [#configuration-keys]
Every telemetry key above is published to the OpenTelemetry SDK as its
corresponding `OTEL_*` variable, and a variable already set in the environment
is left alone. A deployment can therefore repoint an application at a different
collector without a rebuild.
***
## 3. Environment Variable Injection Syntaxes [#3-environment-variable-injection-syntaxes]
Ginboot supports four distinct syntax styles for injecting environment variables dynamically inside your YAML configuration files:
| Syntax Pattern | Example | Description |
| :---------------------- | :------------------------------------------ | :----------------------------------------------------------------------------------- |
| **Braced with Default** | `${SERVICE_USER_URL:http://localhost:8081}` | Uses `SERVICE_USER_URL` if defined; otherwise falls back to `http://localhost:8081`. |
| **Standard Braced** | `${DATABASE_URL}` | Expands `DATABASE_URL` environment variable directly. |
| **Function Style** | `env(PORT, 8080)` | Function-style helper syntax. |
| **Prefix Style** | `$SERVICE_USER_URL` | Simple prefix syntax. |
### Accessing Loaded Configuration in Code [#accessing-loaded-configuration-in-code]
```go
server := ginboot.New()
cfg := server.Config()
log.Printf("Server Port: %d", cfg.Ginboot.Server.Port)
log.Printf("Database URL: %s", cfg.Ginboot.DB.URL)
log.Printf("User Service URL: %s", cfg.Ginboot.Services["user-service"].URL)
```
***
## 4. Live Hot-Reloading with Air (`.air.toml`) [#4-live-hot-reloading-with-air-airtoml]
Ginboot natively supports Air for instant hot-reloading during development.
When running in debug mode, Ginboot ensures a `.air.toml` configuration file is automatically generated in your project root if one is not present. The generated configuration watches `.go`, `.yml`, `.yaml`, and `.env` files.
### Starting Air [#starting-air]
```bash
# Install air CLI (if not already installed)
go install github.com/air-verse/air@latest
# Run air in your project root
air
```
Changes made to `.go` files, `ginboot.yml`, or `.env` will instantly trigger a recompile and server restart.
# Error Handling (/docs/2-core-concepts/error-handling)
Ginboot provides a clean, standardized approach to error handling across your microservices using the `ApiError` interface and the `SendError` utility.
## The `ApiError` Struct [#the-apierror-struct]
To ensure consistency in API responses, Ginboot defines an `ApiError` struct containing an `error_code` and a `message`.
```go
type ApiError struct {
ErrorCode string `json:"error_code"`
Message string `json:"message"`
}
```
### Creating Custom Errors [#creating-custom-errors]
You can define reusable domain errors within your application using `ginboot.NewApiError(httpStatusCode, message)`:
```go
import "github.com/klass-lk/ginboot"
var (
ErrUserNotFound = ginboot.NewApiError(404, "User with ID %s not found")
ErrInvalidInput = ginboot.NewApiError(400, "Invalid input provided")
)
```
You can then format these errors dynamically at runtime:
```go
// Creates an ApiError with Message: "User with ID 123 not found"
err := ErrUserNotFound.New("123")
```
## Sending Errors in Controllers [#sending-errors-in-controllers]
When an error occurs in your controller, you can use `ginboot.SendError(c, err)` to automatically format and send the response back to the client.
```go
func (c *UserController) GetUser(ctx *gin.Context) {
userId := ctx.Param("id")
user, err := c.UserService.FindById(userId)
if err != nil {
// Automatically maps the HTTP status code from the ApiError
// and sends `{ "error_code": "404", "message": "User with ID 123 not found" }`
ginboot.SendError(ctx, ErrUserNotFound.New(userId))
return
}
ctx.JSON(200, user)
}
```
If a standard Go `error` (not an `ApiError`) is passed to `SendError`, Ginboot will safely wrap it in a 500 Internal Server Error response to prevent exposing stack traces to clients.
# Routing (/docs/2-core-concepts/routing)
Ginboot provides a flexible and intuitive routing system built on top of Gin, enhancing it with controller-based organization, flexible handler signatures, and integrated context utilities.
## Core Concepts [#core-concepts]
### Controller Interface [#controller-interface]
Controllers in Ginboot are responsible for grouping related routes and their handlers. Any struct intended to be a controller must implement the `Controller` interface, which requires a `Register` method:
```go
type Controller interface {
Register(group *ControllerGroup)
}
```
The `Register` method is where you define all the routes and their associated handlers for that controller.
### ControllerGroup [#controllergroup]
`ControllerGroup` is a wrapper around Gin's `*gin.RouterGroup`. It provides methods for registering routes (`GET`, `POST`, etc.) and creating nested sub-groups, while also integrating with Ginboot's custom `Context` and `FileService`.
## Registering Controllers and Routes [#registering-controllers-and-routes]
### Server-Level Registration [#server-level-registration]
You register controllers with the main `Server` instance using `RegisterController`. This method automatically creates a `ControllerGroup` for your controller's base path and calls its `Register` method.
```go
package main
import (
"log"
"github.com/klass-lk/ginboot"
"your-project/internal/controller"
"your-project/internal/service"
)
// Example UserController
type UserController struct {
service *service.UserService
}
func NewUserController(s *service.UserService) *UserController {
return &UserController{service: s}
}
func (c *UserController) ListUsers(ctx *ginboot.Context) ([]string, error) {
// ... logic to list users ...
return []string{"user1", "user2"}, nil
}
func (c *UserController) GetUser(ctx *ginboot.Context) (string, error) {
userID := ctx.Param("id")
// ... logic to get user by ID ...
return fmt.Sprintf("User: %s", userID), nil
}
func (c *UserController) Register(group *ginboot.ControllerGroup) {
group.GET("", c.ListUsers) // GET /users
group.GET("/:id", c.GetUser) // GET /users/:id
}
func main() {
server := ginboot.New()
server.SetBasePath("/api/v1")
userService := service.NewUserService() // Assume this exists
userController := NewUserController(userService)
// Register the UserController with a base path of "/users"
server.RegisterController("/users", userController) // Routes will be /api/v1/users, /api/v1/users/:id
log.Fatal(server.Start(8080))
}
```
### Base Path Configuration [#base-path-configuration]
You can set a global base path for all routes registered with the server using `server.SetBasePath()`. This path will prefix all controller and group paths.
```go
server := ginboot.New()
server.SetBasePath("/api/v1") // All routes will be prefixed with /api/v1
```
### Route Groups [#route-groups]
Ginboot allows you to organize routes into groups, which can share a common path prefix and middleware. You can create groups at the server level or nested within other `ControllerGroup`s.
### Controller-Level Groups [#controller-level-groups]
Ginboot strongly recommends that all routes are registered *only* within a Controller. Do not register routes directly on the server instance in `main.go`. Instead, use `group.Group()` inside your controller's `Register` method.
#### Nested Route Groups [#nested-route-groups]
You can create nested groups with shared middleware using the `Group` method on an existing `ControllerGroup`. This is perfect for versioning or applying specific middleware like authentication.
```go
func (c *AdminController) Register(group *ginboot.ControllerGroup) {
// Standard unauthenticated route
group.GET("/ping", c.Ping)
// Create a protected sub-group using middleware
protected := group.Group("/v1", middleware.Auth(), middleware.AdminOnly())
{
protected.POST("/users", c.CreateUser)
protected.PUT("/users/:id", c.UpdateUser)
protected.DELETE("/users/:id", c.DeleteUser)
}
}
```
### HTTP Methods [#http-methods]
`ControllerGroup` provides methods for all standard HTTP verbs:
```go
group.GET("", handler) // GET request
group.POST("", handler) // POST request
group.PUT("", handler) // PUT request
group.DELETE("", handler) // DELETE request
group.PATCH("", handler) // PATCH request
group.OPTIONS("", handler) // OPTIONS request
group.HEAD("", handler) // HEAD request
```
### Path Parameters [#path-parameters]
Ginboot supports Gin's path parameter syntax, allowing you to capture values from the URL.
```go
group.GET("/:id", controller.GetUser) // Matches /users/123, :id captures "123"
group.GET("/:type/*path", controller.GetFile) // Matches /files/image/avatar.png, :type captures "image", *path captures "avatar.png"
```
## Handler Function Signatures [#handler-function-signatures]
Ginboot offers flexibility in defining your handler functions. The framework's internal `wrapHandler` mechanism automatically adapts your handler's signature to Gin's requirements, handling request parsing, context injection, and error management. All handlers must return two values: a response value (can be any type) and an error value.
At a glance, pick the shape that matches what the handler actually needs:
You need context utilities — auth, pagination, path params — but no request body.
You only need the parsed and validated request body.
No input at all — health checks, static lookups, simple stats.
You need both: context utilities and the parsed request body.
### 1. Context Only Handler [#1-context-only-handler]
Use this pattern when your handler needs direct access to Ginboot's custom `Context` utilities (e.g., `GetAuthContext`, `GetPageRequest`, `Param`).
```go
func (c *Controller) ListApiKeys(ctx *ginboot.Context) (*ApiKeyList, error) {
authContext, err := ctx.GetAuthContext()
if err != nil {
return nil, err
}
pageRequest := ctx.GetPageRequest()
// ... use authContext and pageRequest ...
return &ApiKeyList{}, nil
}
```
### 2. Request Model Handler [#2-request-model-handler]
This pattern is ideal when your handler primarily processes a request body. Ginboot will automatically parse and validate the request body into the provided struct.
```go
type CreateApiKeyRequest struct {
Name string `json:"name" binding:"required"`
}
func (c *Controller) CreateApiKey(request CreateApiKeyRequest) (*ApiKey, error) {
// Request is automatically parsed and validated
// Auth context can be accessed through middleware if needed
return &ApiKey{}, nil
}
```
### 3. No Input Handler [#3-no-input-handler]
For simple endpoints that don't require any input parameters or custom context, you can use this concise signature.
```go
func (c *Controller) GetApiKeyStats() (*ApiKeyStats, error) {
// Simple handlers with no input parameters
return &ApiKeyStats{}, nil
}
```
### 4. Context and Request Model Handler [#4-context-and-request-model-handler]
This pattern combines the benefits of both context and request model handlers, allowing access to `ginboot.Context` utilities and automatic request body parsing.
```go
type UpdateUserRequest struct {
Name string `json:"name" binding:"required"`
}
func (c *Controller) UpdateUser(ctx *ginboot.Context, request UpdateUserRequest) (*User, error) {
userID := ctx.Param("id")
// ... use userID from context and data from request ...
return &User{}, nil
}
```
## Middleware [#middleware]
Middleware can be applied at different levels to intercept requests and perform actions like authentication, logging, or data transformation.
### Group Middleware [#group-middleware]
Apply middleware to an entire `ControllerGroup` when creating it. This is useful for protecting a set of routes with common logic, such as authentication.
```go
import (
"github.com/klass-lk/ginboot"
"your-project/internal/middleware"
)
// Assuming middleware.Auth() and middleware.AdminOnly() are gin.HandlerFunc
adminGroup := server.Group("/admin", middleware.Auth(), middleware.AdminOnly())
{
// All routes within this group will use Auth() and AdminOnly() middleware
adminGroup.GET("/stats", adminController.GetStats)
adminGroup.POST("/settings", adminController.UpdateSettings)
}
```
### Route-Specific Middleware [#route-specific-middleware]
You can also apply middleware to individual routes by passing them as additional arguments to the HTTP method functions.
```go
import (
"github.com/gin-gonic/gin"
"github.com/klass-lk/ginboot"
"your-project/internal/middleware"
)
// Assuming middleware.Cache() is a gin.HandlerFunc
group.GET("/users", middleware.Cache(), controller.ListUsers)
```
For server-wide middleware, refer to the [Server Configuration Documentation](server.mdx).
# Server Configuration (/docs/2-core-concepts/server)
This document provides detailed information on configuring and managing the Ginboot server, including how to start it, configure CORS, and apply middleware.
## Initializing the Server [#initializing-the-server]
The `ginboot.New()` function creates a new `Server` instance. It automatically detects if it's running in an AWS Lambda environment by checking the `LAMBDA_TASK_ROOT` environment variable. If detected, the runtime is set to `RuntimeLambda`; otherwise, it defaults to `RuntimeHTTP`.
```go
import "github.com/klass-lk/ginboot"
// Create a new server instance
server := ginboot.New()
```
## Starting the Server [#starting-the-server]
The `Start` method initiates the server. The behavior depends on the detected or explicitly set runtime.
### Basic HTTP Server [#basic-http-server]
To start the server as a standard HTTP application, simply call `Start` with the desired port.
```go
import (
"log"
"github.com/klass-lk/ginboot"
)
func main() {
server := ginboot.New()
// ... register controllers and middleware ...
// Start the server on port 8080
err := server.Start(8080)
if err != nil {
log.Fatal(err)
}
}
```
### AWS Lambda Support [#aws-lambda-support]
Ginboot seamlessly integrates with AWS Lambda. When running in a Lambda environment (detected by `LAMBDA_TASK_ROOT`), the `Start` method will automatically configure the server to handle API Gateway proxy requests. The `port` argument is ignored in Lambda mode.
```go
import (
"github.com/klass-lk/ginboot"
// Ensure LAMBDA_RUNTIME=true environment variable is set for Lambda mode
)
func main() {
server := ginboot.New()
// ... register controllers and middleware ...
// Start the server (port is ignored in Lambda mode)
server.Start(0)
}
```
You can also explicitly set the runtime using `SetRuntime`:
```go
server := ginboot.New()
server.SetRuntime(ginboot.RuntimeLambda)
server.Start(0)
```
## Base Path Configuration [#base-path-configuration]
You can set a base path for all routes in your application using the `SetBasePath` method. All registered routes will be prefixed with this path.
```go
server := ginboot.New()
server.SetBasePath("/api/v1") // All routes will be prefixed with /api/v1
```
## CORS Configuration [#cors-configuration]
Ginboot provides flexible CORS (Cross-Origin Resource Sharing) configuration options through the `Server` struct, leveraging `github.com/gin-contrib/cors`.
### Default CORS Configuration [#default-cors-configuration]
For quick setup with sensible defaults, use `DefaultCORS()`. This configuration allows all origins, common HTTP methods, and common headers, with a preflight cache of 12 hours.
```go
server := ginboot.New()
server.DefaultCORS() // Allows all origins with common methods and headers
```
The default configuration includes:
* **Allowed Origins**: `*` (all origins)
* **Allowed Methods**: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`
* **Allowed Headers**: `Origin`, `Content-Length`, `Content-Type`, `Authorization`
* **Max Age**: `12 * time.Hour` (preflight cache duration)
### Custom CORS Configuration [#custom-cors-configuration]
For more control, use `CustomCORS` to specify allowed origins, methods, headers, and max age.
```go
import (
"time"
"github.com/klass-lk/ginboot"
)
server := ginboot.New()
server.CustomCORS(
[]string{"http://localhost:3000", "https://yourdomain.com"}, // Allowed origins
[]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, // Allowed methods
[]string{"Origin", "Content-Type", "Authorization", "Accept"}, // Allowed headers
24*time.Hour, // Preflight cache duration
)
```
### Advanced CORS Configuration [#advanced-cors-configuration]
For complete control over all CORS settings provided by `gin-contrib/cors`, use the `WithCORS` method and pass a `cors.Config` struct.
```go
import (
"time"
"github.com/gin-contrib/cors"
"github.com/klass-lk/ginboot"
)
server := ginboot.New()
config := cors.Config{
AllowOrigins: []string{"http://localhost:3000"},
AllowMethods: []string{"GET", "POST"},
AllowHeaders: []string{"Origin"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}
server.WithCORS(&config)
```
## Middleware [#middleware]
Ginboot allows you to apply middleware at different levels: globally (server-wide), to route groups, or to individual routes.
### Server-Wide Middleware [#server-wide-middleware]
To apply middleware globally to all routes handled by the server, use the `Use` method on the `Server` instance.
```go
import (
"github.com/gin-gonic/gin"
"github.com/klass-lk/ginboot"
// Assuming you have a middleware package
"your-project/internal/middleware"
)
func main() {
server := ginboot.New()
// Apply a logger middleware globally
server.Use(gin.Logger())
server.Use(middleware.SomeCustomGlobalMiddleware())
// ... register controllers and start server ...
}
```
For group-specific or route-specific middleware, refer to the [Routing Documentation](routing.mdx).
# Authentication (/docs/3-features/authentication)
Ginboot provides robust tools for handling authentication, including JWT management, password encoding, and a custom `AuthContext` for easy access to authenticated user information.
## API Request Context and Authentication [#api-request-context-and-authentication]
The `ginboot.Context` extends Gin's context with utilities to simplify authentication-related tasks. The `GetAuthContext()` method allows you to retrieve details about the authenticated user.
### `AuthContext` Structure [#authcontext-structure]
```go
type AuthContext struct {
UserID string
UserEmail string
Roles []string
Claims map[string]interface{}
}
```
### Retrieving `AuthContext` [#retrieving-authcontext]
To use `GetAuthContext()`, an authentication middleware must first populate the underlying `gin.Context` with `user_id` and `role` values. If these are not found, `GetAuthContext()` will return an error and the request will be aborted with a `401 Unauthorized` status.
```go
func (c *Controller) GetProtectedData(ctx *ginboot.Context) (interface{}, error) {
authContext, err := ctx.GetAuthContext()
if err != nil {
// Error already handled by SendError in wrapHandler
return nil, err
}
fmt.Printf("Authenticated User ID: %s, Role: %v\n", authContext.UserID, authContext.Roles)
// ... use authContext.UserID or authContext.Roles ...
return gin.H{"message": "Protected data for " + authContext.UserID}, nil
}
```
## JWT (JSON Web Token) Management [#jwt-json-web-token-management]
Ginboot includes utilities in the `jwt.go` package for generating, parsing, and validating JWTs. These functions rely on environment variables for secret keys.
### Environment Variables [#environment-variables]
* `JWT_SECRET`: Secret key for signing and verifying access tokens.
* `JWT_REFRESH_SECRET`: Secret key for signing and verifying refresh tokens.
### Generating Tokens [#generating-tokens]
Use `GenerateTokens` to create a pair of access and refresh tokens for a given user ID and role.
```go
import (
"fmt"
"github.com/klass-lk/ginboot"
os
)
func init() {
// Set environment variables for demonstration
os.Setenv("JWT_SECRET", "supersecretaccesskey")
os.Setenv("JWT_REFRESH_SECRET", "supersecretrefreshkey")
}
func main() {
accessToken, refreshToken, err := ginboot.GenerateTokens("user123", "admin")
if err != nil {
fmt.Println("Error generating tokens:", err)
return
}
fmt.Println("Access Token:", accessToken)
fmt.Println("Refresh Token:", refreshToken)
}
```
### Parsing and Extracting Claims [#parsing-and-extracting-claims]
You can parse tokens and extract their claims to retrieve user information.
```go
import (
"fmt"
"github.com/klass-lk/ginboot"
os
)
func init() {
// Set environment variables for demonstration
os.Setenv("JWT_SECRET", "supersecretaccesskey")
os.Setenv("JWT_REFRESH_SECRET", "supersecretrefreshkey")
}
func main() {
accessToken, _, _ := ginboot.GenerateTokens("user123", "admin")
parsedToken, err := ginboot.ParseAccessToken(accessToken)
if err != nil {
fmt.Println("Error parsing token:", err)
return
}
claims, err := ginboot.ExtractClaims(parsedToken)
if err != nil {
fmt.Println("Error extracting claims:", err)
return
}
userID := ginboot.ExtractUserId(claims)
role := ginboot.ExtractRole(claims)
fmt.Printf("Extracted User ID: %s, Role: %s\n", userID, role)
if ginboot.IsExpired(claims) {
fmt.Println("Token is expired")
} else {
fmt.Println("Token is valid")
}
}
```
## Password Encoding [#password-encoding]
Ginboot provides a `PasswordEncoder` interface and a `PBKDF2Encoder` implementation for secure password hashing and verification.
### `PasswordEncoder` Interface [#passwordencoder-interface]
```go
type PasswordEncoder interface {
GetPasswordHash(password string) (string, error)
IsMatching(hash, password string) bool
}
```
### `PBKDF2Encoder` [#pbkdf2encoder]
This implementation uses PBKDF2 with SHA512 for strong password hashing. It requires specific environment variables for configuration.
### Environment Variables [#environment-variables-1]
* `PBKDF2_ENCODER_SECRET`: A secret string used as a salt for hashing.
* `PBKDF2_ENCODER_ITERATION`: The number of iterations for the PBKDF2 algorithm (e.g., `10000`).
* `PBKDF2_ENCODER_KEY_LENGTH`: The desired length of the derived key (e.g., `32`).
### Usage Example [#usage-example]
```go
import (
"fmt"
"github.com/klass-lk/ginboot"
os
)
func init() {
// Set environment variables for demonstration
os.Setenv("PBKDF2_ENCODER_SECRET", "randomsaltstring")
os.Setenv("PBKDF2_ENCODER_ITERATION", "10000")
os.Setenv("PBKDF2_ENCODER_KEY_LENGTH", "32")
}
func main() {
encoder := ginboot.NewPBKDF2Encoder()
password := "mySecurePassword123"
hashedPassword, err := encoder.GetPasswordHash(password)
if err != nil {
fmt.Println("Error hashing password:", err)
return
}
fmt.Println("Hashed Password:", hashedPassword)
// Verify a matching password
if encoder.IsMatching(hashedPassword, password) {
fmt.Println("Password matches!")
} else {
fmt.Println("Password does NOT match.")
}
// Verify a non-matching password
if encoder.IsMatching(hashedPassword, "wrongpassword") {
fmt.Println("Wrong password matches (ERROR)!")
} else {
fmt.Println("Wrong password does not match (CORRECT).")
}
}
```
## Integrating Custom Authentication Middleware [#integrating-custom-authentication-middleware]
To integrate authentication into your Ginboot application, you typically create a Gin middleware that processes authentication credentials (e.g., JWTs from headers) and populates the `gin.Context` with user information. This information can then be accessed via `ginboot.Context.GetAuthContext()`.
Here's an example of a simple JWT authentication middleware:
```go
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/klass-lk/ginboot"
)
func JWTAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Bearer token not found"})
return
}
token, err := ginboot.ParseAccessToken(tokenString)
if err != nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
claims, err := ginboot.ExtractClaims(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"})
return
}
// Set user information in Gin context for ginboot.Context.GetAuthContext()
c.Set("user_id", ginboot.ExtractUserId(claims))
c.Set("role", ginboot.ExtractRole(claims))
// Optionally set other claims or user details
// c.Set("user_email", claims["email"])
// c.Set("claims", claims)
c.Next()
}
}
```
This middleware can then be applied globally, to a group, or to specific routes as described in the [Routing Documentation](routing.mdx).
# AWS Lambda Support (/docs/3-features/aws-lambda)
One of Ginboot's most powerful features is its seamless ability to switch between a traditional HTTP server and an AWS Lambda execution environment without modifying your controllers or business logic.
## How It Works [#how-it-works]
Ginboot automatically detects the AWS Lambda runtime environment variables. If present, it wraps the Gin Engine with the `aws-lambda-go-api-proxy` adapter.
```go
package main
import (
"log"
"os"
"github.com/klass-lk/ginboot"
lambdarunner "github.com/klass-lk/ginboot/runtime/lambda"
)
func main() {
app := ginboot.New()
// Setup your routes and dependencies...
app.SetBasePath("/api")
// Detect if running inside AWS Lambda
if os.Getenv("LAMBDA_TASK_ROOT") != "" || os.Getenv("AWS_EXECUTION_ENV") != "" {
log.Println("Detected AWS Lambda environment...")
// Optionally attach the Ginboot Scheduler for cron events
app.SetRunner(lambdarunner.NewRunnerWithScheduler(app.Scheduler()))
}
// In Lambda, this will block and handle API Gateway proxy events.
// Locally, this will start a normal HTTP server on port 8080.
if err := app.Start(8080); err != nil {
log.Fatalf("Failed to start: %v", err)
}
}
```
## Telemetry on Lambda [#telemetry-on-lambda]
If you use [telemetry](/docs/3-features/telemetry), the Lambda runner takes care of one problem that is specific to this runtime, and you do not have to configure anything for it.
Telemetry is batched rather than exported as it is produced, so that no request waits on a network round trip. On a server the batch leaves a moment later and nobody notices. On Lambda nobody is running a moment later: the execution environment is frozen the instant your handler returns, and a frozen process exports nothing. The freeze does not pause the export's clock either — a suspended request still has a wall-clock deadline, so by the time the environment thaws the deadline has passed and the export fails with `context deadline exceeded`. A handler that returns in a few milliseconds never wins that race and loses **all** of its telemetry, not some of it.
So the runner registers itself as a Lambda [internal extension](https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html) and drains telemetry in the window Lambda leaves open between your response going out and the environment freezing.
Lambda returns the response to the caller as soon as your handler produces it, whether or not extensions are still running. The drain happens after that.
It does extend the **invocation**, and billed duration covers the runtime plus its extensions. On a handler that runs for 2ms, a 200ms drain is 200ms of billed time. Watch the `PostRuntimeExtensionsDuration` CloudWatch metric for the real figure, and prefer a collector in the same region as your function — it is the same round trip either way, so a shorter one costs less.
Bound the drain with `GINBOOT_TELEMETRY_FLUSH_TIMEOUT` (default `2s`; accepts a duration such as `500ms`, or a bare number read as milliseconds):
```bash
GINBOOT_TELEMETRY_FLUSH_TIMEOUT=500ms
```
None of this applies to an HTTP server, which is never frozen and whose exporters run continuously in the background. The machinery lives entirely in the `runtime/lambda` module and does nothing unless `AWS_LAMBDA_RUNTIME_API` is present, so an application serving HTTP never pays for it — and one with no telemetry compiled in does not register an extension at all, since holding the environment open to drain nothing would be billed time for no telemetry.
If registration fails, the function still serves. You get a log line and best-effort exports, which is exactly the behaviour of a runner without an extension.
## AWS SAM Configuration [#aws-sam-configuration]
To deploy your Ginboot application using AWS Serverless Application Model (SAM), you simply need a `template.yaml`.
```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Ginboot Serverless Application
Globals:
Function:
Timeout: 30
MemorySize: 256
Runtime: provided.al2
Architectures:
- arm64
Resources:
GinbootAPI:
Type: AWS::Serverless::Function
Properties:
CodeUri: bin/
Handler: bootstrap # Go binaries in provided.al2 must be named bootstrap
Environment:
Variables:
GIN_MODE: release
Events:
CatchAll:
Type: Api
Properties:
Path: /{proxy+}
Method: ANY
```
To build and deploy:
```bash
# Build the binary for AL2 ARM64
GOOS=linux GOARCH=arm64 go build -o bin/bootstrap main.go
# Deploy using SAM
sam deploy --guided
```
This single-binary deployment makes Ginboot extremely cost-effective, leveraging Go's incredibly fast cold-start times on AWS Lambda.
# Caching (/docs/3-features/caching)
Ginboot provides a unified caching layer that supports **DynamoDB**, **SQL**, and **MongoDB** backends. It integrates seamlessly with Gin middleware, offering automatic response caching and tag-based invalidation.
## Architecture [#architecture]
The caching system consists of the following components:
* **`CacheEntry`**: The data structure stored in the database.
* **`CacheService`**: Interface for `Set`, `Get`, and `Invalidate` operations.
* **`CacheMiddleware`**: Gin middleware that automatically caches GET responses.
* **Generic Repository Enhancements**: Repositories now support bulk operations like `DeleteBy` to facilitate efficient invalidation.
## Supported Backends [#supported-backends]
### 1. DynamoDB [#1-dynamodb]
Requires a `CacheEntry` table and a `TagEntry` table (using single-table design principles).
```go
client := ginboot.NewDynamoDBClient(cfg)
cacheService := ginboot.NewDynamoDBCacheService(client)
```
**Schema**:
* `CacheEntry`: PK=`CACHE#`, SK=`DATA`
* `TagEntry`: PK=`TAG#`, SK=`CACHE#`
### 2. SQL [#2-sql]
Requires `cache_entries` and `cache_tags` tables.
```go
cacheRepo := ginboot.NewSQLRepository[ginboot.CacheEntry](db)
tagRepo := ginboot.NewSQLRepository[ginboot.TagEntry](db)
cacheService := ginboot.NewSQLCacheService(cacheRepo, tagRepo)
```
### 3. MongoDB [#3-mongodb]
Requires a single collection (default: `cache_entries`) with a `tags` array field.
```go
cacheRepo := ginboot.NewMongoRepository[ginboot.CacheEntry](db, "cache_entries")
cacheService := ginboot.NewMongoCacheService(cacheRepo)
```
## Usage [#usage]
### Middleware Setup [#middleware-setup]
Use `CacheMiddleware` to cache responses for GET requests. You can define a custom `TagGenerator` to tag cache entries for later invalidation.
```go
// Define a Tag Generator
tagGen := func(c *gin.Context) []string {
// Tag by resource type or ID
return []string{"posts"}
}
// Initialize Middleware
cacheMiddleware := ginboot.CacheMiddleware(
cacheService,
10 * time.Minute, // TTL
tagGen, // Tag Generator
nil, // Default Key Generator
)
// Apply to routes
router.GET("/posts", cacheMiddleware, postController.GetPosts)
```
### Automatic Invalidation [#automatic-invalidation]
Invalidate cache entries when data changes (e.g., in `Create`, `Update`, `Delete` handlers/services).
```go
func (c *PostController) UpdatePost(ctx *ginboot.Context, post model.Post) {
// ... update logic
// Invalidate all entries tagged with "posts"
c.cacheService.Invalidate(ctx, "posts")
}
```
### Manual Invalidation Controller [#manual-invalidation-controller]
You can expose an endpoint to manually invalidate tags.
```go
func (c *CacheController) Invalidate(ctx *ginboot.Context) (ginboot.EmptyResponse, error) {
tag := ctx.Query("tag")
if tag == "" {
return ginboot.EmptyResponse{}, ginboot.ApiError{ErrorCode: "BAD_REQUEST", Message: "Tag is required"}
}
// Invalidate
err := c.cacheService.Invalidate(context.Background(), tag)
return ginboot.EmptyResponse{}, err
}
```
# Database Support (/docs/3-features/database)
Ginboot provides a powerful and flexible multi-database support system through a generic repository interface. This allows you to interact with different database systems (MongoDB, SQL, DynamoDB) using a consistent API, making your application more modular, testable, and adaptable to various data storage needs.
## Generic Repository Interface [#generic-repository-interface]
The core of Ginboot's database abstraction is the `GenericRepository[T any]` interface. This interface defines a comprehensive set of common data access operations, ensuring a uniform way to interact with different database types.
```go
type GenericRepository[T any] interface {
FindById(id string) (T, error)
FindAllById(ids []string) ([]T, error)
Save(doc T) error
SaveOrUpdate(doc T) error
SaveAll(docs []T) error
Update(doc T) error
Delete(id string) error
FindOneBy(field string, value interface{}) (T, error)
FindOneByFilters(filters map[string]interface{}) (T, error)
FindBy(field string, value interface{}) ([]T, error)
FindByFilters(filters map[string]interface{}) ([]T, error)
FindAll(options ...interface{}) ([]T, error)
FindAllPaginated(pageRequest PageRequest) (PageResponse[T], error)
FindByPaginated(pageRequest PageRequest, filters map[string]interface{}) (PageResponse[T], error)
CountBy(field string, value interface{}) (int64, error)
CountByFilters(filters map[string]interface{}) (int64, error)
ExistsBy(field string, value interface{}) (bool, error)
ExistsByFilters(filters map[string]interface{}) (bool, error)
}
```
### `Document` Interface [#document-interface]
For SQL and DynamoDB repositories, your data models must implement the `Document` interface, which provides the table/collection name.
```go
type Document interface {
GetTableName() string
}
```
### Pagination Structures [#pagination-structures]
Ginboot provides standardized structures for handling pagination requests and responses.
```go
type SortField struct {
Field string `json:"field"`
Direction int `json:"direction"` // 1 for ascending, -1 for descending
}
type PageRequest struct {
Page int `json:"page"`
Size int `json:"size"`
Sort SortField `json:"sort"`
}
type PageResponse[T interface{}] struct {
Contents []T `json:"content"`
NumberOfElements int `json:"numberOfElements"`
Pageable PageRequest `json:"pageable"`
TotalPages int `json:"totalPages"`
TotalElements int `json:"totalElements"`
}
```
## Choosing a backend [#choosing-a-backend]
The three supported backends share the interface above — pick the tab for the one you're using.
Ginboot offers robust support for MongoDB through `MongoConfig` for connection management and `MongoRepository` for data operations.
### MongoDB Configuration [#mongodb-configuration]
Use `ginboot.NewMongoConfig()` to build your MongoDB connection string. You can specify host, port, credentials, database name, and additional options.
```go
import (
"log"
"github.com/klass-lk/ginboot"
)
func connectMongo() *mongo.Database {
config := ginboot.NewMongoConfig().
WithHost("localhost", 27017).
WithDatabase("mydatabase").
WithCredentials("myuser", "mypassword").
WithOption("authSource", "admin")
db, err := config.Connect()
if err != nil {
log.Fatalf("Failed to connect to MongoDB: %v", err)
}
fmt.Println("Connected to MongoDB!")
return db
}
```
### MongoDB Repository Example [#mongodb-repository-example]
Define your document struct with `bson` tags for MongoDB field mapping and a `ginboot:"_id"` tag for the primary key if it's not named `ID`.
```go
import (
"fmt"
"go.mongodb.org/mongo-driver/mongo"
"github.com/klass-lk/ginboot"
)
type User struct {
ID string `bson:"_id" ginboot:"_id"` // ginboot:_id helps the repository identify the ID field
Name string `bson:"name"`
Age int `bson:"age"`
}
// NewMongoRepository creates a new MongoDB repository instance.
// The collection name is typically the plural of your entity name.
func NewUserRepository(db *mongo.Database) *UserRepository {
return &UserRepository{
MongoRepository: ginboot.NewMongoRepository[User](db, "users"),
}
}
// Example usage of the MongoDB repository
func main() {
db := connectMongo() // Assume connectMongo() returns *mongo.Database
repo := ginboot.NewMongoRepository[User](db, "users")
// Save a new user
user := User{ID: "1", Name: "John Doe", Age: 30}
err := repo.Save(user)
if err != nil { log.Fatal(err) }
fmt.Println("User saved:", user.Name)
// Find user by ID
foundUser, err := repo.FindById("1")
if err != nil { log.Fatal(err) }
fmt.Println("Found user:", foundUser.Name)
// Update user
foundUser.Age = 31
err = repo.Update(foundUser)
if err != nil { log.Fatal(err) }
fmt.Println("User updated:", foundUser.Name)
// Find users by filter
filters := map[string]interface{}{"age": 31}
users, err := repo.FindByFilters(filters)
if err != nil { log.Fatal(err) }
fmt.Println("Users with age 31:", len(users))
// Paginated query
pageRequest := ginboot.PageRequest{Page: 1, Size: 10, Sort: ginboot.SortField{Field: "name", Direction: 1}}
pageResponse, err := repo.FindAllPaginated(pageRequest)
if err != nil { log.Fatal(err) }
fmt.Println("Paginated results:", len(pageResponse.Contents))
}
```
Ginboot provides a generic repository interface for SQL databases, allowing you to interact with relational databases like PostgreSQL or MySQL using a consistent API.
### SQL Configuration [#sql-configuration]
Use `ginboot.NewSQLConfig()` to configure your SQL connection. You need to specify the `Driver` (e.g., "postgres", "mysql"), host, port, credentials, and database name.
```go
import (
"log"
"database/sql"
"github.com/klass-lk/ginboot"
_ "github.com/lib/pq" // Import the PostgreSQL driver
)
func connectSQL() *sql.DB {
config := ginboot.NewSQLConfig().
WithDriver("postgres").
WithHost("localhost", 5432).
WithDatabase("testdb").
WithCredentials("postgres", "password").
WithOption("sslmode", "disable")
db, err := config.Connect()
if err != nil {
log.Fatalf("Failed to connect to PostgreSQL: %v", err)
}
fmt.Println("Connected to PostgreSQL!")
return db
}
```
### SQL Repository Example [#sql-repository-example]
Your SQL document struct must implement the `Document` interface and use `db` tags to map fields to database columns. The `ID` field is assumed to be the primary key.
```go
import (
"fmt"
"log"
"database/sql"
"time"
"github.com/klass-lk/ginboot"
)
type Product struct {
ID string `db:"id"`
Name string `db:"name"`
Price float64 `db:"price"`
CreatedAt time.Time `db:"created_at"`
}
func (p Product) GetTableName() string {
return "products"
}
// Example usage of the SQL repository
func main() {
db := connectSQL() // Assume connectSQL() returns *sql.DB
repo := ginboot.NewSQLRepository[Product](db)
// Ensure the table exists (optional, can be done once at startup)
err := repo.CreateTable()
if err != nil { log.Fatal(err) }
// Save a new product
product := Product{ID: "p1", Name: "Laptop", Price: 1200.00, CreatedAt: time.Now()}
err = repo.Save(product)
if err != nil { log.Fatal(err) }
fmt.Println("Product saved:", product.Name)
// Find product by ID
foundProduct, err := repo.FindById("p1")
if err != nil { log.Fatal(err) }
fmt.Println("Found product:", foundProduct.Name)
// Update product
foundProduct.Price = 1150.00
err = repo.Update(foundProduct)
if err != nil { log.Fatal(err) }
fmt.Println("Product updated:", foundProduct.Name)
// Find products by filter
filters := map[string]interface{}{"name": "Laptop"}
products, err := repo.FindByFilters(filters)
if err != nil { log.Fatal(err) }
fmt.Println("Products named Laptop:", len(products))
// Delete product
err = repo.Delete("p1")
if err != nil { log.Fatal(err) }
fmt.Println("Product deleted.")
}
```
Ginboot provides robust support for AWS DynamoDB, offering a similar generic repository interface for interacting with NoSQL tables.
### DynamoDB Configuration [#dynamodb-configuration]
Use `ginboot.NewDynamoConfig()` to configure your DynamoDB client. You can specify the AWS region, credentials (access key and secret key), a custom endpoint (useful for local DynamoDB), or an AWS profile.
```go
import (
"log"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/klass-lk/ginboot"
)
func connectDynamoDB() *dynamodb.Client {
config := ginboot.NewDynamoConfig().
WithRegion("us-east-1").
WithEndpoint("http://localhost:8000") // For local DynamoDB
// .WithCredentials("your-access-key", "your-secret-key")
// .WithProfile("your-aws-profile")
client, err := config.Connect()
if err != nil {
log.Fatalf("Failed to connect to DynamoDB: %v", err)
}
fmt.Println("Connected to DynamoDB!")
return client
}
```
### DynamoDB Repository Example [#dynamodb-repository-example]
Your DynamoDB document struct must implement the `Document` interface and use `dynamodbav` tags to map fields to DynamoDB attributes. The `ID` field is assumed to be the primary key.
```go
import (
"fmt"
"log"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/klass-lk/ginboot"
)
type Order struct {
ID string `ginboot:"id"`
CustomerID string `dynamodbav:"customer_id"`
Amount float64 `dynamodbav:"amount"`
Status string `dynamodbav:"status"`
}
func (o Order) GetTableName() string {
return "orders"
}
// Example usage of the DynamoDB repository
func main() {
client := connectDynamoDB() // Assume connectDynamoDB() returns *dynamodb.Client
// The last parameter (skipTableCreation) can be set to true if you manage table creation externally
repo := ginboot.NewDynamoDBRepository[Order](client, "orders", false)
// Save a new order
order := Order{ID: "o1", CustomerID: "cust123", Amount: 99.99, Status: "PENDING"}
err := repo.Save(order)
if err != nil { log.Fatal(err) }
fmt.Println("Order saved:", order.ID)
// Find order by ID
foundOrder, err := repo.FindById("o1")
if err != nil { log.Fatal(err) }
fmt.Println("Found order:", foundOrder.ID, "Status:", foundOrder.Status)
// Update order
foundOrder.Status = "COMPLETED"
err = repo.Update(foundOrder)
if err != nil { log.Fatal(err) }
fmt.Println("Order updated:", foundOrder.ID, "Status:", foundOrder.Status)
// Find orders by filter (Note: DynamoDB Scan operations can be inefficient for large tables)
filters := map[string]interface{}{"customer_id": "cust123"}
orders, err := repo.FindByFilters(filters)
if err != nil { log.Fatal(err) }
fmt.Println("Orders for customer cust123:", len(orders))
// Delete order
err = repo.Delete("o1")
if err != nil { log.Fatal(err) }
fmt.Println("Order deleted.")
}
```
### Considerations for DynamoDB Performance [#considerations-for-dynamodb-performance]
`FindOneBy`, `FindOneByFilters`, `FindBy`, `FindByFilters`, `CountBy`, `CountByFilters` and the
pagination methods all use DynamoDB's `Scan` when filtering on non-primary-key attributes. `Scan`
reads every item in the table, so it gets slow and expensive as the table grows.
* **Global Secondary Indexes (GSIs):** For better performance on frequently queried non-primary key fields, consider defining Global Secondary Indexes (GSIs) on your DynamoDB tables. Ginboot's generic repository methods do not automatically leverage GSIs; you would typically use the underlying `*dynamodb.Client` directly for GSI-based queries or extend the repository to include GSI-aware methods.
* **Pagination:** DynamoDB's native pagination uses `ExclusiveStartKey` rather than traditional offset/limit. Ginboot's pagination methods simulate offset/limit by performing multiple `Scan` operations and discarding items, which can be inefficient for deep pagination. For optimal performance with large datasets, consider implementing cursor-based pagination directly using DynamoDB's `ExclusiveStartKey`.
## Customizing Repositories [#customizing-repositories]
You can easily extend Ginboot's generic repositories to add database-specific methods or custom business logic. This is done by embedding the generic repository within your own custom repository struct.
```go
import (
"go.mongodb.org/mongo-driver/mongo"
"github.com/klass-lk/ginboot"
)
type UserRepository struct {
*ginboot.MongoRepository[User] // Embed the generic repository
}
func NewUserRepository(db *mongo.Database) *UserRepository {
return &UserRepository{
MongoRepository: ginboot.NewMongoRepository[User](db, "users"),
}
}
// Add a custom method specific to UserRepository
func (r *UserRepository) FindUsersByStatus(status string) ([]User, error) {
// You can use the embedded generic repository methods
return r.FindBy("status", status)
}
// Or implement a completely custom query
func (r *UserRepository) GetActiveUsersCount() (int64, error) {
// Access the underlying collection directly if needed
// return r.collection.CountDocuments(context.Background(), bson.M{"status": "active"})
return r.CountBy("status", "active")
}
```
# Event Triggers (/docs/3-features/event-triggers)
A Ginboot application can be woken by three things: an HTTP request, a clock, and an event. This page is the third.
You declare in code that you consume a queue. Ginboot Cloud reads that declaration from the running application, creates the queue, wires it to your function, and tells your code where it landed.
## Declaring a consumer [#declaring-a-consumer]
```go
package main
import (
"context"
"github.com/klass-lk/ginboot"
"github.com/my-project/models"
)
func main() {
app := ginboot.New()
app.RegisterConsumer(ginboot.NewQueueConsumer("sms", ginboot.Queue("sms"),
func(ctx context.Context, sms models.SMS) error {
return smsService.Send(ctx, sms)
}))
app.Start(8080)
}
```
The message body is decoded into your type the same way a route handler binds a request body. A message that cannot be decoded fails, and after five attempts lands in the dead letter queue — it is never deleted unread.
`ginboot.Queue("sms")` is a *logical* name, local to your application. The real queue is named for your application and environment, so two applications may both have an `sms` queue.
## Sending to the queue [#sending-to-the-queue]
```go
url, err := ginboot.QueueURL("sms")
if err != nil {
// ginboot.ErrQueueNotProvisioned — see "Two deployments" below.
return err
}
```
`QueueURL` reads the variable the platform injects once the queue exists. It returns `ErrQueueNotProvisioned` rather than an empty string, because "not deployed yet" and "you asked for a queue you never declared" are both an absence and only one of them is fixed by deploying again.
Do not call `MustQueueURL` while your application is starting. A panic there would stop the application before it can serve the manifest that gets the queue created.
## Two deployments [#two-deployments]
A trigger is discovered by asking the running application, which can only happen once it is running. So the queue arrives on the deployment *after* the one that introduced the consumer:
1. **First deploy.** Your consumer is registered and recorded. The console lists it and says the queue has not been created yet. `QueueURL` returns `ErrQueueNotProvisioned`.
2. **Second deploy.** The queue, its dead letter queue and the mapping are created. Messages flow.
The console says this plainly rather than leaving you to discover it. It is the same two-step that [background workers](/docs/3-features/workers) go through, one turn sharper — here the *sending* side is affected too.
## What gets created [#what-gets-created]
For each managed queue:
| Resource | Setting | Why |
| ----------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Queue | `VisibilityTimeout: 960` | Above the worker's 900-second timeout, so a message is never redelivered while your handler is still working on it |
| Queue | `MessageRetentionPeriod: 14 days` | The maximum SQS allows |
| Dead letter queue | `maxReceiveCount: 5` | A message that cannot be handled goes somewhere, instead of occupying the consumer forever |
| Mapping | `ReportBatchItemFailures` | One bad message in a batch of ten redelivers only itself |
Your application's execution role is granted access to its own queues automatically. You do not need to write an IAM statement for a queue you did not name.
## Partial batch failures [#partial-batch-failures]
Messages are handled one at a time, and only the ones that fail are redelivered:
```go
app.RegisterConsumer(ginboot.NewQueueConsumer("orders", ginboot.Queue("orders"),
func(ctx context.Context, order models.Order) error {
if err := billing.Charge(ctx, order); err != nil {
// This message is retried. The others in the batch are not.
return err
}
return nil
}))
```
A handler that panics is one failed message, not a lost batch. Messages already handled in the same batch are never redelivered because of it.
## Batch size and FIFO [#batch-size-and-fifo]
```go
// At most five messages per invocation.
ginboot.NewQueueConsumerWithBatchSize("bulk", ginboot.Queue("bulk"), 5, handle)
// A FIFO queue. Every send needs a message group id.
ginboot.NewQueueConsumer("ledger", ginboot.FIFOQueue("ledger"), handle)
```
## Using a queue you already have [#using-a-queue-you-already-have]
```go
ginboot.NewQueueConsumer("legacy",
ginboot.ExternalQueue("arn:aws:sqs:ap-southeast-1:123456789012:legacy-queue"),
handle)
```
Ginboot subscribes your function to it and grants access, but does not create, configure or delete it.
Two things stay yours. Declare it by **full ARN** — a bare name cannot say which region the queue is in, and the manifest reports a bare name as undeployable rather than letting it silently receive nothing. And set its **visibility timeout to at least 900 seconds**: the SQS default of 30 means your handler is still running when the message is handed to a second invocation.
## Testing locally [#testing-locally]
Registered consumers are listed at `/_ginboot/triggers`. In debug mode you can also deliver a message by hand:
```bash
curl -X POST localhost:8080/_ginboot/triggers/sms \
-d '{"to":"+94771234567","text":"hello"}'
```
This runs the real handler through the real dispatch path — same spans, same panic containment, same error reporting — and tells you what happened. It is absent from any build not running in debug mode, because it invokes your application code with a caller-supplied payload.
## Lambda [#lambda]
Use `NewRunnerFor`, which wires both your consumers and your scheduled workers:
```go
if os.Getenv("LAMBDA_TASK_ROOT") != "" {
app.SetRunner(lambdarunner.NewRunnerFor(app))
}
```
`NewRunner()` and `NewRunnerWithScheduler()` still work but are deprecated. `NewRunner()` in particular wires neither workers nor consumers, and gives no sign of it — a registered worker that never runs looks exactly like one that is not due yet.
# OpenAPI & Swagger (/docs/3-features/openapi-swagger)
Ginboot comes with built-in reflection-based OpenAPI 3.0 schema generation. It inspects your controllers, request bodies, query parameters, and response structs to automatically build an accurate OpenAPI JSON specification.
## How it works [#how-it-works]
When you define your Ginboot controllers, the framework dynamically inspects the Types of the arguments passed to your handler methods.
For instance, if your method signature takes a `*CreateUserRequest` struct, Ginboot parses its `json` and `form` tags and automatically maps them to OpenAPI parameters and requestBody definitions.
## Exporting the Specification [#exporting-the-specification]
To export the generated Swagger specification locally, simply start your Ginboot application with the `GINBOOT_EXPORT_SWAGGER` environment variable set to your desired output path.
```bash
GINBOOT_EXPORT_SWAGGER=./docs/swagger.json go run main.go
```
The framework will generate the spec at startup:
```json
{
"openapi": "3.0.0",
"info": {
"title": "Ginboot Application API",
"version": "1.0.0"
},
"paths": {
"/api/v1/users": {
"post": {
"summary": "",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"username": { "type": "string" },
"email": { "type": "string" }
}
}
}
}
},
"responses": {
"200": {
"description": "Successful operation"
}
}
}
}
}
}
```
You can then serve this file using a Swagger UI docker container or any API documentation portal.
# Inter-Service Communication (/docs/3-features/service-communication)
Ginboot provides a high-level, protocol-agnostic service-to-service communication layer. Developers can invoke other microservices or serverless functions using logical service identifiers directly on `ginboot.Context` without handling transport details, JSON serialization, or header propagation manually.
***
## 1. Developer API [#1-developer-api]
`ginboot.Context` provides two primary invocation modes:
### Synchronous Request-Reply (`ctx.CallService`) [#synchronous-request-reply-ctxcallservice]
Performs a blocking request to the target service and automatically unmarshals the response payload into a target struct.
```go
func (c *OrderController) GetOrderDetails(ctx *ginboot.Context) (*OrderDetailsDTO, error) {
userID := ctx.Param("userId")
// Synchronous call to user-service
var user UserResponseDTO
err := ctx.CallService("user-service", "/api/v1/users/"+userID, nil, &user)
if err != nil {
return nil, ginboot.NewApiError(404, "User details could not be retrieved")
}
return &OrderDetailsDTO{
OrderID: "ord-99",
User: user,
}, nil
}
```
### Non-blocking Fire-and-Forget Async (`ctx.CallServiceAsync`) [#non-blocking-fire-and-forget-async-ctxcallserviceasync]
Executes a non-blocking asynchronous call in the background without waiting for a response or blocking the caller HTTP context.
```go
func (c *OrderController) CreateOrder(ctx *ginboot.Context, req CreateOrderRequest) (*OrderResponseDTO, error) {
order, err := c.orderService.CreateOrder(req)
if err != nil {
return nil, err
}
// Fire-and-forget async notification (non-blocking)
_ = ctx.CallServiceAsync("notification-service", "/api/v1/notifications/send", map[string]interface{}{
"type": "ORDER_CREATED",
"order_id": order.ID,
"user_id": order.UserID,
})
return order, nil
}
```
***
## 2. HTTP Method Overrides [#2-http-method-overrides]
`CallService` and `CallServiceAsync` issue a `POST` unless you say otherwise. Read-only calls
should use the `WithMethod` variants below, or you'll `POST` to a `GET` endpoint and get a 405.
You can specify a custom HTTP method (GET, PUT, DELETE, PATCH) using:
```go
// Synchronous GET
err := ctx.CallServiceWithMethod("GET", "user-service", "/api/v1/users/"+id, nil, &user)
// Asynchronous DELETE
err := ctx.CallServiceAsyncWithMethod("DELETE", "cache-service", "/api/v1/cache/purge", nil)
```
***
## 3. Dynamic Service Name Resolution (`ServiceResolver`) [#3-dynamic-service-name-resolution-serviceresolver]
Target endpoints are resolved automatically at runtime using `ServiceResolver`:
1. **`ginboot.yml` Mapping**: Reads endpoint URLs configured under `ginboot.services..url` — see [Configuration](/docs/2-core-concepts/configuration#2-declarative-ginbootyml--applicationyml-file) for the full file layout.
2. **Ginboot Cloud UI & Environment Variables**: Automatically checks environment variable `SERVICE__URL` (e.g. `SERVICE_USER_SERVICE_URL=https://user-service.cloud.internal`).
3. **Local Fallback**: Defaults to `http://:8080` for local Docker/K8s development.
***
## 4. Context & Header Propagation [#4-context--header-propagation]
`ServiceClient` automatically propagates essential request context headers across downstream HTTP requests:
* **OpenTelemetry Tracing**: Formats and injects W3C trace context headers (`traceparent`, `tracestate`) to preserve distributed tracing spans across service boundaries.
* **Request Identification**: Propagates `X-Request-ID`.
* **Authentication Context**: Forwards user credentials and authorization claims (`Authorization`, `X-User-ID`, `X-User-Roles`).
# S3 Storage (/docs/3-features/storage-s3)
Ginboot abstracts file uploading and management through its `FileService` interface. You can bind an AWS S3 File Service to your application, which automatically handles uploading files to S3 buckets or falling back to a local disk directory.
## Initializing the S3 File Service [#initializing-the-s3-file-service]
To attach file upload capabilities, initialize the `s3.NewS3FileService` and bind it to your engine.
```go
package main
import (
"context"
"github.com/klass-lk/ginboot"
"github.com/klass-lk/ginboot/storage/s3"
)
func main() {
app := ginboot.New()
// Create the S3 file service
// The service requires the bucket name, a local fallback path,
// AWS credentials (or IAM roles if empty), region, and a pre-signed URL expiration time.
fileService := s3.NewS3FileService(
context.Background(),
"my-app-bucket-name",
"uploads/", // Local fallback path if AWS keys aren't provided
"AWS_ACCESS_KEY", // Leave empty to use IAM / Default Provider Chain
"AWS_SECRET_KEY",
"ap-southeast-1",
"3600", // Default expiration for signed URLs (in seconds)
)
// Bind it to the Ginboot Engine
app.BindFileService(fileService)
app.Start(8080)
}
```
## Using the File Service in Controllers [#using-the-file-service-in-controllers]
Once bound, you can retrieve the file service from the Gin context inside any controller using `ginboot.GetFileService(ctx)`.
```go
import "github.com/klass-lk/ginboot"
func (c *MediaController) UploadAvatar(ctx *gin.Context) {
file, err := ctx.FormFile("avatar")
if err != nil {
ginboot.SendError(ctx, ErrInvalidInput.New("No file uploaded"))
return
}
fs := ginboot.GetFileService(ctx)
// Upload the file to the S3 Bucket (or local path)
// Returns a unique key/URL depending on the implementation
uploadPath, err := fs.Upload(ctx, file)
if err != nil {
ginboot.SendError(ctx, ErrUploadFailed)
return
}
ctx.JSON(200, gin.H{
"message": "Avatar uploaded successfully",
"url": uploadPath,
})
}
```
This abstraction ensures that your business logic remains completely unaware of whether it is running locally or in a cloud environment.
# Telemetry & Observability (/docs/3-features/telemetry)
# Telemetry & Observability [#telemetry--observability]
Ginboot ships OpenTelemetry tracing, metrics, request IDs and trace-correlated logging as a plugin. One import turns it on; `ginboot.yml` or the standard `OTEL_*` environment variables decide the rest. You never write setup or shutdown code.
The plugin lives in its own module, `github.com/klass-lk/ginboot/telemetry`, so an application that does not import it never carries the OpenTelemetry SDK at all.
***
## 1. Turn it on [#1-turn-it-on]
Add the import. It is blank — you are not calling anything, you are compiling the plugin in so it can register itself with the framework:
```go
package main
import (
"log"
"github.com/klass-lk/ginboot"
_ "github.com/klass-lk/ginboot/telemetry" // registers the telemetry plugin
)
func main() {
server := ginboot.New()
server.RegisterController("/orders", controller.NewOrderController())
log.Fatal(server.Start(8080))
}
```
This import is required. Without it there is no telemetry plugin in the binary, and configuration asking for telemetry has nothing to switch on. Ginboot says so at startup rather than staying silent:
```
[ginboot] telemetry was requested (ginboot.yml or OTEL_EXPORTER_OTLP_ENDPOINT)
but no instrumentation is registered; add: import _ "github.com/klass-lk/ginboot/telemetry"
```
Projects created with `ginboot new --telemetry` already have the import and the configuration.
***
## 2. How it decides to run [#2-how-it-decides-to-run]
With the plugin compiled in, either of these switches it on:
| Signal | Meaning |
| :------------------------------------------------------------------------------------------------------------------------ | :---------------------------------------------------- |
| `telemetry.enabled: true` in `ginboot.yml` | You asked for it |
| `OTEL_EXPORTER_OTLP_ENDPOINT` set in the environment
(or the signal-specific `..._TRACES_/_METRICS_/_LOGS_ENDPOINT`) | Something has pointed this application at a collector |
The second exists because configuration files do not always survive deployment. A build that ships a compiled binary and nothing else has no `ginboot.yml` at runtime, so `telemetry.enabled` reads as `false` no matter what the repository says. A platform that has gone to the trouble of injecting an endpoint has expressed the intent plainly enough — see [Deploying](#5-deploying).
With neither, nothing is installed and telemetry costs nothing. That is the normal state on a development machine.
### Turning it off [#turning-it-off]
Set OpenTelemetry's own switch, which beats both signals above:
```bash
OTEL_SDK_DISABLED=true
```
Use it when an environment names a collector that this particular service should not talk to. Only a value that parses as true disables anything — a typo will not silence your service.
***
## 3. Configuration (`ginboot.yml`) [#3-configuration-ginbootyml]
```yaml
ginboot:
telemetry:
enabled: true
service-name: order-service
service-version: v1.0.0
environment: production
exporter: otlp
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:}
headers: ${OTEL_EXPORTER_OTLP_HEADERS}
protocol: ${OTEL_EXPORTER_OTLP_PROTOCOL:http/protobuf}
resource-attributes: ${OTEL_RESOURCE_ATTRIBUTES}
```
These values are published to the OpenTelemetry SDK as the `OTEL_*` variables it reads. **A variable already present in the environment is left alone**, so a deployment can point an application at a different collector without a rebuild.
Leave `endpoint` empty unless you mean it. With no endpoint the SDK installs providers that export nowhere, which costs nothing and is what you want on a laptop. Naming a collector that is not running does not fail quietly — every batch is attempted and every failure is logged.
***
## 4. Environment variables [#4-environment-variables]
Every standard OpenTelemetry variable works, because the SDK reads them directly:
```bash
# Where to send it
OTEL_EXPORTER_OTLP_ENDPOINT="https://otlp-gateway-prod.grafana.net/otlp"
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20MTEyNj..."
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
# How it is labelled
OTEL_SERVICE_NAME="order-service"
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,team=payments"
# How much to record — see Sampling
OTEL_TRACES_SAMPLER="parentbased_traceidratio"
OTEL_TRACES_SAMPLER_ARG="0.1"
```
***
## 5. Deploying [#5-deploying]
Ginboot reads `ginboot.yml` from the working directory at startup. On a serverless runtime that directory contains exactly what your deployment package contains, so **a config file left out of the package does not exist at runtime**, and every setting in it silently takes its zero value.
Either ship it alongside the binary:
```bash
zip function.zip bootstrap ginboot.yml
```
…or rely on the endpoint rule from [section 2](#2-how-it-decides-to-run) and configure the deployment entirely through `OTEL_*` environment variables. Both work; the second is usually simpler, since a platform that hosts your application is already injecting them.
Ginboot Cloud does both: it packages `ginboot.yml` with the binary and injects the `OTEL_*` variables for every environment.
***
## 6. Sampling [#6-sampling]
Sampling is how you trade completeness for overhead, and it is configured entirely through the standard variables:
```bash
OTEL_TRACES_SAMPLER="parentbased_traceidratio"
OTEL_TRACES_SAMPLER_ARG="0.1" # record one request in ten
```
Unset, the SDK records everything and respects an upstream caller's decision not to sample (`parentbased_always_on`).
***
## 7. Taking control in code [#7-taking-control-in-code]
The import covers the common case. Call the API directly when you need something it does not express — a service name computed at runtime, a custom logger, or payload capture turned on in code:
```go
import (
"context"
"github.com/klass-lk/ginboot"
"github.com/klass-lk/ginboot/telemetry"
)
func main() {
server := ginboot.New()
shutdown, err := telemetry.Setup(context.Background(), "order-service", "v1.0.0")
if err != nil {
log.Printf("continuing without telemetry: %v", err)
}
defer shutdown(context.Background())
telemetry.InstrumentWithOptions(server, "order-service", nil, telemetry.CaptureOptions{
RequestBodies: true,
MaxBytes: 4096,
})
server.Start(8080)
}
```
Instrumenting twice is not additive — it would double every span, log line and metric — so the first caller wins and later ones do nothing. Mixing the blank import with an explicit `Instrument` call is therefore safe.
Where a process exits deliberately, `server.Shutdown(ctx)` drains whatever is still buffered. It is not useful on a runtime that is suspended rather than stopped, such as AWS Lambda, which never gets far enough to run it — that case is handled for you, see below.
***
## 8. On AWS Lambda [#8-on-aws-lambda]
Batching assumes the process is still running a moment later to send the batch. Lambda freezes the execution environment the instant your handler returns, and the export's wall-clock deadline keeps running while it is frozen, so a fast handler loses **all** of its telemetry rather than some of it.
The `runtime/lambda` runner handles this: it registers as a Lambda internal extension and drains in the window between your response going out and the environment freezing. You configure nothing.
It does not delay the caller — Lambda returns the response before extensions finish — but the drain is inside the billed invocation. See [Telemetry on Lambda](/docs/3-features/aws-lambda#telemetry-on-lambda) for the cost and for `GINBOOT_TELEMETRY_FLUSH_TIMEOUT`.
An HTTP server needs none of this. It is never frozen, its exporters run continuously, and nothing on the request path ever waits for a drain.
***
## 9. What you get [#9-what-you-get]
* **Distributed tracing**: W3C Trace Context (`traceparent`) propagated into and out of every service call.
* **Trace-correlated logs**: `ctx.Logger().Info("Created order")` carries the active `trace_id` and `span_id`, so a log line leads back to the request that wrote it.
* **Metrics**: HTTP request duration histograms, error rates, and Go runtime memory stats.
* **Request IDs**: an `X-Request-ID` on every request, recorded on the span.
### Cost [#cost]
Nothing here sits on the path of a request. Setting up exporters builds clients without dialing anything, and each record a request produces is handed to a batch processor that exports from its own goroutine — if its queue is full it drops rather than blocking your handler. Payload capture is off unless you ask for it.
***
## 10. Context-bound logger [#10-context-bound-logger]
Any log written through the request context is correlated with the current trace:
```go
func (c *UserController) GetUser(ctx *ginboot.Context) (interface{}, error) {
// Automatically carries trace_id and span_id
ctx.Logger().Info("Fetching user from database", "user_id", 123)
// ...
}
```
By default the plugin prints human-readable logs to the terminal while shipping structured logs to your OTLP backend. To use your own instead, implement `ginboot.Logger` and inject it:
```go
server.SetLogger(myCustomFileLogger)
```
# Background Workers (/docs/3-features/workers)
Ginboot includes a built-in scheduler to manage background tasks and periodic jobs asynchronously. This allows you to offload heavy processing or run scheduled cleanups without blocking your main HTTP requests.
## The Worker Interface [#the-worker-interface]
For structured background jobs, Ginboot defines a `Worker` interface.
```go
type Worker interface {
Name() string
Interval() time.Duration
Execute(ctx context.Context) error
}
```
### Creating a Worker [#creating-a-worker]
You can create a struct that implements this interface. For example, here is a worker that cleans up stale telemetry data every hour:
```go
package workers
import (
"context"
"time"
)
type TelemetryCleanupWorker struct {}
func NewTelemetryCleanupWorker() *TelemetryCleanupWorker {
return &TelemetryCleanupWorker{}
}
func (w *TelemetryCleanupWorker) Name() string {
return "TelemetryCleanupWorker"
}
func (w *TelemetryCleanupWorker) Interval() time.Duration {
return 1 * time.Hour // Runs every 1 hour
}
func (w *TelemetryCleanupWorker) Execute(ctx context.Context) error {
// Execute your background task logic here
// e.g. db.Exec("DELETE FROM logs WHERE created_at < NOW() - INTERVAL '7 days'")
return nil
}
```
## Registering Workers [#registering-workers]
Once you have defined your worker, you can register it with the Ginboot Engine before calling `app.Start()`.
```go
package main
import (
"github.com/klass-lk/ginboot"
"github.com/my-project/workers"
)
func main() {
app := ginboot.New()
// Registering a structured worker
app.RegisterWorkerStruct(workers.NewTelemetryCleanupWorker())
app.Start(8080)
}
```
### Simple Inline Workers [#simple-inline-workers]
If you don't need a full struct and just want to run a simple function periodically, you can use `RegisterWorker`:
```go
import "time"
import "context"
import "log"
app.RegisterWorker("SimpleLogger", 10 * time.Second, func(ctx context.Context) error {
log.Println("10 seconds have passed!")
return nil
})
```
## AWS Lambda Compatibility [#aws-lambda-compatibility]
When running in an AWS Lambda environment via `lambdarunner.NewRunnerWithScheduler`, these background workers can be triggered automatically by EventBridge (CloudWatch Events) using cron schedules!
# Changelog (/docs/4-advanced/changelog)
All notable changes, new features, architectural improvements, and deprecations in the **Ginboot Framework** are documented here.
***
## \[Unreleased] [#unreleased]
### 🔭 Telemetry [#-telemetry]
#### 1. `ginboot.yml`'s telemetry block now does something [#1-ginbootymls-telemetry-block-now-does-something]
* `ginboot.New()` reads `telemetry.enabled` and installs tracing, metrics, request IDs and trace-correlated logging, before any route is registered.
* Previously the block was parsed and ignored: telemetry only ran if an application called `telemetry.Setup` and `telemetry.Instrument` by hand.
* **Requires a blank import**, which is what compiles the plugin in and lets it register itself:
```go
import _ "github.com/klass-lk/ginboot/telemetry"
```
Applications that do not import it are unaffected and still do not carry the OpenTelemetry SDK. Configuration that asks for telemetry the binary cannot provide now says so at startup instead of staying silent.
#### 2. An OTLP endpoint in the environment also enables telemetry [#2-an-otlp-endpoint-in-the-environment-also-enables-telemetry]
* `OTEL_EXPORTER_OTLP_ENDPOINT` — or the signal-specific `..._TRACES_/_METRICS_/_LOGS_ENDPOINT` — turns instrumentation on by itself.
* This is for deployments that ship a compiled binary and no config file, where `ginboot.yml` is not there to be read and `telemetry.enabled` would be `false` however the repository has it.
* `OTEL_SDK_DISABLED=true` beats both signals, for a service whose environment names a collector it should not talk to.
#### 3. `OTEL_TRACES_SAMPLER` is honoured **(behaviour change)** [#3-otel_traces_sampler-is-honoured-behaviour-change]
* Sampling was hardcoded to `AlwaysSample`, which silently overrode `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG`. A service turning the ratio down to shed load stayed at full volume.
* Unset, the SDK now defaults to `parentbased_always_on`: the same volume as before, but an upstream caller's decision not to sample is respected rather than overridden.
#### 4. Instrumenting twice is now a no-op [#4-instrumenting-twice-is-now-a-no-op]
* Middleware installed a second time doubled every span, log line and metric for the life of the process. An application that both imports the plugin and calls `Instrument` itself would have done exactly that.
* The first caller wins; later ones do nothing.
#### 5. `server.Shutdown(ctx)` [#5-servershutdownctx]
* Drains buffered telemetry where a process ends deliberately. Not useful on a runtime that is suspended rather than stopped, such as AWS Lambda.
#### 6. Telemetry now survives AWS Lambda [#6-telemetry-now-survives-aws-lambda]
* Lambda freezes the execution environment the moment a handler returns, and an export's wall-clock deadline keeps running while it is frozen. A fast handler therefore lost **all** of its telemetry to `context deadline exceeded`, not merely some of it.
* `runtime/lambda` now registers as a Lambda internal extension and drains telemetry in the window between the response going out and the environment freezing. Nothing to configure.
* **Responses are not delayed** — Lambda returns the response before extensions finish — but the drain is inside the billed invocation. Bound it with `GINBOOT_TELEMETRY_FLUSH_TIMEOUT` (default `2s`) and watch `PostRuntimeExtensionsDuration`.
* HTTP servers are unaffected: the machinery lives in `runtime/lambda`, is inert unless `AWS_LAMBDA_RUNTIME_API` is set, and does not register at all when no telemetry is compiled in. A failed registration leaves the function serving exactly as before.
***
## \[v1.1.0] - 2026-07-29 [#v110---2026-07-29]
### 🚀 New Features & Enhancements [#-new-features--enhancements]
#### 1. Spring-Boot Style Declarative Configuration (`ginboot.yml`) [#1-spring-boot-style-declarative-configuration-ginbootyml]
* Introduced unified YAML configuration parsing (`ginboot.yml`, `application.yml`, `ginboot.yaml`, `application.yaml`).
* Config structure supporting `server`, `services`, `db`, and `telemetry` sections.
* Access server configuration anywhere via `server.Config()`.
#### 2. Automatic `.env` Environment File Loading [#2-automatic-env-environment-file-loading]
* Automatically loads `.env`, `.env.local`, and `.env.development` files into process environment variables without needing third-party loader boilerplate in `main.go`.
* Added support for 4 environment variable injection syntaxes inside YAML config files:
* `${VAR:default}`
* `${VAR}`
* `env(VAR, default)`
* `$VAR`
#### 3. Inter-Service Communication Layer [#3-inter-service-communication-layer]
* Added high-level service communication methods to `ginboot.Context`:
* `ctx.CallService("service-name", path, payload, &response)` (synchronous request-reply)
* `ctx.CallServiceAsync("service-name", path, payload)` (non-blocking fire-and-forget)
* Implemented `ConfigServiceResolver` supporting dynamic endpoint resolution via `SERVICE__URL` environment variables and Ginboot Cloud UI environment injection.
* Automatic OpenTelemetry W3C trace context header propagation (`traceparent`, `tracestate`) across downstream service calls.
#### 4. Live Hot-Reloading with Air (`.air.toml`) [#4-live-hot-reloading-with-air-airtoml]
* Added generic `.air.toml` configuration generator (`EnsureAirConfig`).
* Ginboot applications running in debug mode automatically ensure `.air.toml` is created in the project root watching `.go`, `.yml`, `.yaml`, and `.env` files for seamless live hot-reloading.
***
## \[v1.0.0] - Initial Release [#v100---initial-release]
* Core controller registration system (`server.RegisterController`).
* Flexible handler signatures returning `(ResponseDTO, error)`.
* OpenTelemetry instrumentation and Grafana OTLP exporting.
* MongoDB and InMemory repository generic abstractions.
* AWS S3 Storage Service integration.
* AWS Lambda serverless execution mode support.
# Deployment (/docs/4-advanced/deployment)
Ginboot applications are designed for easy deployment to AWS Lambda using the AWS Serverless Application Model (SAM) CLI. This document guides you through the process of building and deploying your Ginboot application as a serverless function.
## Prerequisites [#prerequisites]
Before you begin, ensure you have the following installed and configured:
* **Go 1.21 or later**: The Go runtime is required to build your application.
* **AWS SAM CLI**: Install the AWS SAM CLI to build, test, and deploy your serverless applications. Follow the official AWS documentation for installation instructions.
* **AWS CLI**: Ensure you have the AWS Command Line Interface (CLI) installed and configured with appropriate credentials and a default region. Your AWS credentials will be used by SAM CLI to deploy resources to your AWS account.
## Project Structure for SAM Deployment [#project-structure-for-sam-deployment]
When you create a new Ginboot project using `ginboot new myproject`, it generates a project structure that is compatible with AWS SAM. The `main.go` file in the `cmd` directory typically contains the entry point for your Lambda function.
```
myproject/
├── cmd/
│ └── main.go
├── internal/
│ └── ... (your application logic)
├── template.yaml # AWS SAM template
└── ... (other project files)
```
## Building Your Application for Lambda [#building-your-application-for-lambda]
Ginboot provides a CLI tool to simplify the build process for AWS Lambda. This command compiles your Go application and prepares it for deployment.
```bash
ginboot build
```
This command will:
1. Compile your Go application for the `linux/amd64` architecture, which is compatible with AWS Lambda.
2. Place the compiled binary (e.g., `bootstrap`) in a `build/` directory.
3. Generate a `template.yaml` file (if not already present or updated) that defines your serverless application's resources, including the Lambda function, API Gateway, and any other AWS services.
## Deploying to AWS [#deploying-to-aws]
Once your application is built, you can deploy it to your AWS account using the Ginboot CLI tool, which internally leverages the AWS SAM CLI.
```bash
ginboot deploy
```
### First-Time Deployment [#first-time-deployment]
On your first deployment, the SAM CLI will prompt you for several configuration details:
1. **Stack Name**: A unique name for your CloudFormation stack (e.g., `myproject-stack`). This defaults to your project name.
2. **AWS Region**: The AWS region where you want to deploy your application (e.g., `us-east-1`).
3. **S3 Bucket**: An S3 bucket name to store your deployment artifacts. If you don't have one, SAM CLI can create one for you.
These settings will be saved in a `ginboot-app.yml` file in your project root. This file is used for subsequent deployments, so you won't be prompted for these details again unless you delete the file or change the configuration.
### Subsequent Deployments [#subsequent-deployments]
For subsequent deployments, simply run `ginboot deploy` again. SAM CLI will detect changes in your code or `template.yaml` and perform an incremental update to your CloudFormation stack.
## Understanding `template.yaml` [#understanding-templateyaml]
The `template.yaml` file is the heart of your SAM application. It defines your serverless resources. A typical Ginboot `template.yaml` might look like this:
```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: A Ginboot application deployed to AWS Lambda.
Globals:
Function:
Timeout: 30 # Default timeout for all functions
MemorySize: 128 # Default memory for all functions
Resources:
GinbootFunction:
Type: AWS::Serverless::Function
Properties:
Handler: bootstrap # The name of your compiled Go binary
Runtime: go1.x
CodeUri: build/ # Path to your compiled binary
Architectures:
- x86_64
Events:
Api: # This defines an API Gateway endpoint
Type: Api
Properties:
Path: /{proxy+}
Method: ANY
```
### Key Sections in `template.yaml` [#key-sections-in-templateyaml]
* **`Transform: AWS::Serverless-2016-10-31`**: Specifies that this is a SAM template.
* **`Globals`**: Defines properties that apply to all resources of a certain type (e.g., `Function`).
* **`Resources`**: Declares the AWS resources that make up your application.
* **`GinbootFunction`**: This is your Lambda function.
* **`Handler: bootstrap`**: Points to the compiled Go binary named `bootstrap` (generated by `ginboot build`).
* **`Runtime: go1.x`**: Specifies the Go runtime for Lambda.
* **`CodeUri: build/`**: Indicates that the Lambda code is located in the `build/` directory.
* **`Events.Api`**: Configures an API Gateway endpoint that triggers your Lambda function for any HTTP method and path (`/{proxy+}`).
## Local Testing with SAM CLI [#local-testing-with-sam-cli]
Before deploying, you can test your Lambda function locally using the SAM CLI.
### Build Locally [#build-locally]
```bash
sam build
```
### Run Locally [#run-locally]
```bash
sam local start-api
```
This will start a local API Gateway endpoint (usually `http://127.0.0.1:3000`) that proxies requests to your local Lambda function. You can then use tools like `curl` or Postman to test your API endpoints.
## Troubleshooting [#troubleshooting]
* **`go: command not found`**: Ensure Go is installed and its binary directory is in your system's PATH.
* **`sam: command not found`**: Ensure AWS SAM CLI is installed and its binary directory is in your system's PATH.
* **Deployment Errors**: Check the CloudFormation events in the AWS console for detailed error messages if your deployment fails.
* **Lambda Function Logs**: Use AWS CloudWatch Logs to view the logs generated by your Lambda function during execution.
# Ginboot Cloud (/docs/4-advanced/ginboot-cloud)
Ginboot Cloud is our official SaaS platform designed to remove all DevOps friction from managing your Ginboot microservices.
It provides an integrated dashboard for zero-downtime deployments, real-time environment variable management, and centralized log streaming for all your applications.
## Why use Ginboot Cloud? [#why-use-ginboot-cloud]
* **1-Click Deployments:** Connect your GitHub repositories and deploy your Ginboot applications instantly without writing Dockerfiles or Kubernetes manifests.
* **Environment Management:** Manage secrets and environment variables securely across Staging, Production, and Preview environments directly from the dashboard.
* **Log Streaming:** View live server logs instantly without SSH-ing into instances.
* **Automated Webhooks:** Set up custom CI/CD pipelines using our Webhook triggers (`/api/v1/webhooks`).
* **Telemetry Integration:** Works natively with Ginboot's OTLP exporter (`/v1/logs`, `/v1/metrics`) to aggregate and visualize your metrics automatically.
## Agent Integration [#agent-integration]
For hybrid-cloud deployments, you can install the Ginboot Agent on your own AWS EC2 instances or Kubernetes clusters. The agent polls Ginboot Cloud for deployment instructions and executes them securely within your VPC.
# Testing & BDD (/docs/4-advanced/testing)
Ginboot seamlessly integrates with [Godog](https://github.com/cucumber/godog) to provide an incredibly powerful Behavior-Driven Development (BDD) testing framework out of the box.
This allows you to write tests in plain English using Gherkin syntax and have Ginboot's automated step definitions execute them against your API.
## The TestSuite [#the-testsuite]
Ginboot provides a `TestSuite` struct which wires up your `gin.Engine`, in-memory HTTP recorders, and Database Seeders.
```go
package tests
import (
"testing"
"github.com/klass-lk/ginboot"
"github.com/my-project/api"
)
func TestAPI(t *testing.T) {
// 1. Initialize your normal Ginboot App
app := api.SetupApp()
// 2. Create the TestSuite
suite := &ginboot.TestSuite{
Router: app.Engine(),
DbSeeders: make(map[string]ginboot.DBSeeder),
}
// 3. Register your generic DB seeders for testing
// genericSeeder := ginboot.NewGenericDBSeeder(...)
// suite.RegisterDBSeeder("users", genericSeeder)
// 4. Run the BDD tests
ginboot.TestFeatures(t, suite)
}
```
## Writing Gherkin Features [#writing-gherkin-features]
In your `features/` directory, you can write standard Cucumber features. Ginboot has built-in step definitions that automatically parse HTTP methods, paths, bodies, and assert JSON responses!
```gherkin
Feature: User Login
Background: Setup Database
# This automatically uses your registered DBSeeder to inject data!
Given document "users" has the following items
| id | email | password | role |
| 123 | test@test.com | p4ssw0rd | admin |
Scenario: Successful Login
When I send a POST request to "/api/v1/auth/login" with body
| email | password |
| test@test.com | p4ssw0rd |
Then the response status should be 200
And the response should contain an item with
| token |
| ... |
```
## Available Step Definitions [#available-step-definitions]
The `ginboot.TestSuite` comes with the following steps pre-wired:
* `Given document "[collection]" has the following items`
* `When I send a [GET/POST/PUT/DELETE] request to "[path]" with body`
* `When I send an authenticated GET request to "[path]"`
* `Then the response status should be [status_code]`
* `Then the response "[field]" field is stored as "[key]"` (Extracts JWTs or IDs for later steps)
* `Then the response should contain an item with [table]` (Deep JSON assertion)
# Migration Playbook for Coding Agents (/docs/5-migration/agent-playbook)
This page is written for an autonomous or semi-autonomous coding agent performing a
migration to Ginboot. It is imperative, verifiable, and states the API surface exactly so
you do not have to infer it.
Humans migrating by hand should read
[Migrating an Existing Go App](/docs/5-migration/from-go) or
[Porting from Another Language](/docs/5-migration/from-other-languages) instead — the
same work, explained rather than prescribed.
## How to fetch this documentation [#how-to-fetch-this-documentation]
| Resource | URL |
| :------------------------------ | :------------------------------------------------------------------------ |
| Index of all pages | `https://ginboot.com/llms.txt` |
| Full documentation, one request | `https://ginboot.com/llms-full.txt` |
| This page as Markdown | `https://ginboot.com/llms.mdx/docs/5-migration/agent-playbook/content.md` |
| Any page as Markdown | `https://ginboot.com/llms.mdx/docs//content.md` |
| Source repository | `https://github.com/klass-lk/ginboot` |
| Package documentation | `https://pkg.go.dev/github.com/klass-lk/ginboot` |
Fetch `llms-full.txt` once at the start of a migration and work from it. Verify anything
you are unsure of against the installed module in `$GOPATH/pkg/mod` or with
`go doc github.com/klass-lk/ginboot.Server` — not from memory.
***
## Step 0 — Classify the task [#step-0--classify-the-task]
Run this decision before writing any code.
```text
Is the existing API written in Go?
├─ Yes, using Gin → PROCEDURE A (in-place, handlers pass through)
├─ Yes, another router → PROCEDURE A (in-place, handlers rewritten)
└─ No (Node/Python/Java/…) → PROCEDURE B (port + cutover, both services run)
```
State which procedure you selected, and why, before your first edit. If the repository
contains more than one service, migrate exactly one per session.
***
## Ground truth: the API surface [#ground-truth-the-api-surface]
Everything in this section is verified against the current source. Prefer it over
recalled knowledge.
### Module and entrypoint [#module-and-entrypoint]
```go
import "github.com/klass-lk/ginboot"
server := ginboot.New() // loads .env*, then ginboot.yml/application.yml
cfg := server.Config() // *config.Config
server.SetBasePath("/api/v1")
server.RegisterController("/users", userController)
err := server.Start(8080) // blocks
```
`ginboot.New()` loads, in order: `.env`, `.env.local`, `.env.development`, then the first
of `ginboot.yml`, `application.yml`, `ginboot.yaml`, `application.yaml`. Real environment
variables always win over file values.
### Every exported method on `*Server` [#every-exported-method-on-server]
`BindFileService`, `Config`, `Consumers`, `CustomCORS`, `DefaultCORS`, `Engine`, `Group`,
`RegisterConsumer`, `RegisterConsumerErr`, `RegisterContract`, `RegisterController`,
`RegisterWorker`, `RegisterWorkerStruct`, `Scheduler`, `ServiceClient`, `SetBasePath`,
`SetConfig`, `SetLogger`, `SetRunner`, `SetServiceClient`, `Shutdown`, `Start`,
`WithCORS`.
`server.Use(...)`, `server.SetRuntime(...)`, `ginboot.RuntimeLambda`,
`ginboot.RuntimeHTTP`, `ginboot.NewMongoRepository`, `ginboot.NewSQLRepository`,
`ginboot.NewMongoConfig`, `ginboot.NewSQLConfig`, `ginboot.NewDynamoConfig`.
Server-wide middleware is `server.Engine().Use(...)`. Repositories and database configs
live in the `db/*` submodules listed below. Some older pages and blog posts show the
removed forms; the compiler is the authority.
### Controllers and routes [#controllers-and-routes]
```go
type Controller interface {
Register(group *ginboot.ControllerGroup)
}
```
`*ControllerGroup` methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`, `HEAD`,
`Handle`, `Group`, `Use`. Each route method has the signature
`(path string, handler interface{}, middleware ...gin.HandlerFunc)` — note that
**middleware comes after the handler**.
### The four handler signatures [#the-four-handler-signatures]
```go
func(ctx *ginboot.Context) (T, error) // params, auth, pagination
func(req R) (T, error) // bound + validated body only
func(ctx *ginboot.Context, req R) (T, error) // both, in this order
func() (T, error) // no input
func(c *gin.Context) // raw Gin, accepted unchanged
```
Rules enforced at registration time, by panic:
* Exactly two return values, the second assignable to `error` (except the raw Gin form).
* At most two parameters.
* With two parameters, the first **must** be `*ginboot.Context`.
### Response and error behaviour [#response-and-error-behaviour]
| Handler returns | Client receives |
| :---------------------- | :----------------------------------------------------------------------------------- |
| `(value, nil)` | `200` + JSON body |
| `(string, nil)` | `200` + `text/plain; charset=utf-8` |
| `(nil, nil)` | `200`, empty body — unless the handler already wrote a response, which is left alone |
| `(_, ginboot.ApiError)` | The numeric code in the error (400–599), with `{"error_code","message"}` |
| `(_, other error)` | `500` with `{"error_code":"Internal Server Error","message":...}` |
| Bad request body | `400` `BAD_REQUEST`, before the handler runs |
To return a status other than `200` on success, write it and return `nil, nil`:
```go
ctx.JSON(http.StatusCreated, user)
return nil, nil
```
### Context API [#context-api]
`ctx.Param`, `ctx.Query` and every other `*gin.Context` method (it is embedded), plus:
`GetAuthContext() (AuthContext, error)`, `GetPageRequest() PageRequest`,
`GetRequest(&v) error`, `Logger()`, `Span()`, `RecordError(err)`, `SendError(err)`,
`CallService(name, action, payload, &target) error`, `CallServiceAsync`,
`ServiceClient()`, `GetFileService()`.
`GetAuthContext()` reads the Gin context keys **`user_id`** and **`role`**, and returns
`401` if either is missing. Auth middleware must set both:
```go
c.Set("user_id", userID)
c.Set("role", role)
```
### Errors [#errors]
```go
var ErrNotFound = ginboot.NewApiError(404, "User with ID %s not found")
return model.User{}, ErrNotFound.New(id) // New() formats the message
```
### Repositories — separate modules [#repositories--separate-modules]
| Backend | `go get` | Import + constructor |
| :---------------- | :------------------------------------------- | :---------------------------------------------- |
| MongoDB | `github.com/klass-lk/ginboot/db/mongo` | `mongo.NewMongoRepository[T](db, "collection")` |
| SQL (GORM) | `github.com/klass-lk/ginboot/db/sql` | `sql.NewSQLRepository[T](gormDB)` |
| DynamoDB | `github.com/klass-lk/ginboot/db/dynamodb` | `dynamodb.NewDynamoDBRepository[T](client)` |
| In-memory | `github.com/klass-lk/ginboot/db/inmemory` | `inmemory.NewInMemoryRepository[T]()` |
| AWS Lambda runner | `github.com/klass-lk/ginboot/runtime/lambda` | `lambda.NewRunnerFor(server)` |
| Telemetry plugin | `github.com/klass-lk/ginboot/telemetry` | blank import |
All repositories implement `ginboot.GenericRepository[T]`: `FindById`, `FindAllById`,
`Save`, `SaveOrUpdate`, `SaveAll`, `Update`, `Delete`, `FindOneBy`, `FindOneByFilters`,
`FindBy`, `FindByFilters`, `FindAll`, `FindAllPaginated`, `FindByPaginated`, `CountBy`,
`CountByFilters`, `ExistsBy`, `ExistsByFilters`, `DeleteAll`.
### Background work [#background-work]
```go
server.RegisterWorker("cleanup", time.Hour, func(ctx context.Context) error { ... })
server.RegisterWorkerStruct(worker) // Name() string; Interval() time.Duration; Execute(ctx) error
server.RegisterConsumer(ginboot.NewQueueConsumer("sms", ginboot.Queue("sms"),
func(ctx context.Context, msg model.SMS) error { ... }))
```
### Provided automatically [#provided-automatically]
`GET /healthz` and `GET /health` (also under the base path), the OpenAPI endpoint, the
workers and triggers endpoints, and `gin.Recovery()`. Do not re-implement these.
`GINBOOT_EXPORT_SWAGGER=openapi.json go run .` writes the spec and exits.
***
## Procedure A — an existing Go service [#procedure-a--an-existing-go-service]
Perform the steps in order. **Run the verification after each step and do not continue
while it fails.** Commit after each step, so any step can be reverted alone.
### A1. Survey, and write the plan down [#a1-survey-and-write-the-plan-down]
Produce, and keep updated, an inventory of: every route (method, path, handler), every
middleware and the order it runs in, every config read, every data-access call site,
every background job, and the test entrypoints.
**Verify:** the route count in your inventory equals the route count the application
prints at startup, or that `grep` finds in the router setup.
### A2. Install and take over the entrypoint [#a2-install-and-take-over-the-entrypoint]
```bash
go get -u github.com/klass-lk/ginboot
```
Replace the router construction with `ginboot.New()`, move server-wide middleware to
`server.Engine().Use(...)` **before** any route registration, and register the existing
route tree on `server.Engine()`. Change nothing else.
**Verify:**
```bash
go build ./... && go vet ./...
go test ./...
# start the app, then:
curl -fsS localhost:8080/healthz
```
Plus one `curl` per route family, compared against responses recorded before the change.
### A3. Configuration [#a3-configuration]
Create `ginboot.yml` using the environment variable names the deployment already sets, and
replace scattered `os.Getenv` calls with `server.Config()`.
```yaml
ginboot:
server:
port: ${PORT:8080}
base-path: /api/v1
env: ${ENV:development}
```
**Verify:** the app starts both with the real environment and with an empty one. If you
set `base-path`, confirm no route now answers at a doubled prefix.
### A4. Convert one endpoint group [#a4-convert-one-endpoint-group]
Repeat this step once per resource, smallest and least critical first. Do not batch
several groups into one change.
1. Create `internal/controller/_controller.go` with a `Register` method.
2. Convert each handler to one of the four signatures.
3. Move error responses into `ApiError` values returned by the service.
4. Register with `server.RegisterController("/", c)`.
5. Delete the old route registration for that resource.
**Verify:** `go build ./... && go test ./...`, then a response diff for every route in the
group — status code, headers that matter, and body — against the recorded originals.
### A5. Data access [#a5-data-access]
Replace hand-written CRUD with a repository from the matching `db/*` module, embedding it
so custom queries survive. Add the tags the backend needs (`bson`, GORM, `dynamodbav`)
and `GetTableName()` for SQL and DynamoDB models.
**Verify:** repository tests, then the endpoint tests from A4, still pass.
### A6. Background work and platform features [#a6-background-work-and-platform-features]
Convert tickers and cron jobs to `RegisterWorker`/`RegisterWorkerStruct`, and queue
pollers to `RegisterConsumer`. Then add telemetry, caching or the Lambda runner if the
task asks for them.
**Verify:** each worker logs one execution; `go test ./...` passes.
### A7. Delete the old scaffolding [#a7-delete-the-old-scaffolding]
Remove the superseded router setup, manual binding, error formatting and config loading.
An unused parallel implementation is the most expensive thing you can leave behind.
**Verify:** `go build ./... && go vet ./... && go test ./...`, and
`grep -rn "ShouldBindJSON\|gin.Default()\|os.Getenv" --include=*.go .` returns only
deliberate matches.
***
## Procedure B — porting from another language [#procedure-b--porting-from-another-language]
The new service and the old one both run until the cutover completes.
### B1. Capture the contract [#b1-capture-the-contract]
Extract, in this order of preference: the existing OpenAPI document, recorded
request/response traffic, or the route table plus tests. Record for every endpoint:
method, path, parameters, request fields with types and required-ness, response shape,
every status code, and the authentication required.
Save it in the repository — `docs/api-contract.md` or `openapi.json`. It is the
acceptance criterion for everything that follows.
**Verify:** the endpoint count matches the old service's route table exactly.
### B2. Scaffold [#b2-scaffold]
```bash
go mod init
go get -u github.com/klass-lk/ginboot
```
Layout: `cmd/main.go`, `internal/controller`, `internal/service`, `internal/repository`,
`internal/model`, `internal/middleware`, plus `ginboot.yml`.
**Verify:** `go build ./...` and `curl -fsS localhost:8080/healthz`.
### B3. Models [#b3-models]
One Go struct per entity. Tag **every** field with `json` using the exact name the old API
emits. Use pointers for fields that are nullable or whose absence differs from their zero
value.
**Verify:** unmarshal a recorded response body into the struct, marshal it back, and diff.
The diff must be empty.
### B4. Services [#b4-services]
Port the rules, not the syntax. Services take `context.Context` and return domain errors,
and must not import `ginboot` types other than `ApiError`.
Hidden behaviour is the main risk: ORM lifecycle hooks, signals, `before_save` callbacks,
middleware that mutates the request, and framework-implicit defaults. Enumerate them from
the old source and make each one an explicit call.
**Verify:** ported unit tests pass.
### B5. Repositories [#b5-repositories]
Point the repository at the **same database** the old service uses. Do not copy data yet.
**Verify:** reads return values identical to the old service's for the same keys.
### B6. Controllers, validation, auth [#b6-controllers-validation-auth]
One controller per resource. Validation moves into `binding` tags; errors become
`ApiError` values with the **same status codes** the old API returned; auth middleware
sets `user_id` and `role`.
**Verify:** per endpoint, same request → same status, same body. Automate this as a diff
against recorded traffic rather than checking by eye.
### B7. Jobs and consumers — one owner only [#b7-jobs-and-consumers--one-owner-only]
Port scheduled jobs and queue consumers, and disable each one in the old service at the
moment you enable it in the new one. A job running in both services is a data-corruption
bug, not a migration inconvenience.
**Verify:** for every job, exactly one service has it enabled. State this explicitly in
your report.
### B8. Cutover [#b8-cutover]
Move one path prefix at a time at the proxy: read-only endpoints first, then low-traffic
writes, then the core write paths. Keep one proxy rule per path so a rollback is a single
change.
**Verify:** `GINBOOT_EXPORT_SWAGGER=openapi.json go run ./cmd` and diff the result against
the contract from B1.
***
## Rules [#rules]
**Always:**
* Compile and test after every step; never present unverified code as working.
* Register routes inside a controller's `Register` method.
* Return `(T, error)`; let the framework serialise and map.
* Give every `ApiError` the status code the old API used for that condition.
* Tag every struct field with `json`, and with the backend's tag for persisted models.
* Keep the old implementation running and revertible until the new one is verified.
* Check an API against the installed module when you are unsure it exists.
**Never:**
* Emit `server.Use`, `server.SetRuntime` or root-package repository constructors — see the
callout above.
* Write a response body **and** return a non-nil value from the same handler.
* Put middleware before the handler in a route registration; it comes after.
* Invent config keys. The supported set is in [Configuration](/docs/2-core-concepts/configuration).
* Change a path, field name, casing, date format or status code that clients depend on,
as part of a migration. Migrate first, redesign in a separate, announced change.
* Re-implement `/healthz`, request logging, panic recovery or OpenAPI generation.
* Delete the old code before the replacement has passed its verification.
***
## Failure modes and fixes [#failure-modes-and-fixes]
| Symptom | Cause | Fix |
| :---------------------------------------------------------------- | :------------------------------------------------------------------ | :------------------------------------------------------------------- |
| `panic: handler must return (response, error)` | Wrong arity on a converted handler | Return exactly two values, second an `error` |
| `panic: first argument must be *Context when using two arguments` | Parameters in the wrong order | `(ctx *ginboot.Context, req R)` |
| `panic: handler must have 0-2 arguments` | Extra parameters | Read the rest off `ctx` |
| `panic: handler must be a function` | A value passed where a function was expected | Pass the method, do not call it |
| `Headers were already written` | Handler wrote a response and returned a value | Return `nil, nil` after writing |
| Routes at `/api/v1/api/v1/...` | Base path duplicated in route strings | Strip the prefix from route strings |
| `401` on every protected route | Middleware does not set `user_id` and `role` | Set both with `c.Set` |
| Middleware never runs | Registered after the routes | `server.Engine().Use(...)` first |
| Response field renamed to `PascalCase` | Missing `json` tag | Tag every field |
| `undefined: ginboot.NewMongoRepository` | Repositories are in `db/*` submodules | `go get github.com/klass-lk/ginboot/db/mongo` and import it |
| `ErrQueueNotProvisioned` from `QueueURL` | The queue is created on the deploy *after* the consumer is declared | Deploy twice — see [Event Triggers](/docs/3-features/event-triggers) |
| Every success is `200`, contract says `201` | Ginboot's success default | Write the status, return `nil, nil` |
***
## Report back with this [#report-back-with-this]
When you finish, or when you pause, state:
1. Which procedure you ran, and which step you reached.
2. Endpoints migrated, endpoints remaining.
3. The verification output for the last completed step — real command output, not a claim.
4. Any contract change you had to make, and why.
5. Every job or consumer you enabled or disabled, and in which service.
6. What is left, in the order you would do it.
Do not describe a migration as complete while any item on the
[definition of done](/docs/5-migration#definition-of-done) is unmet. Report the state
accurately instead; a partial migration that is honestly described is usable, and one
that is not is worse than none.
# Migrating an Existing Go App (/docs/5-migration/from-go)
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 [#why-this-can-be-incremental]
Ginboot is a layer over [Gin](https://github.com/gin-gonic/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.
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](#coming-from-another-router)
for what changes and what carries over.
***
## Step 1 — Install Ginboot and take over the entrypoint [#step-1--install-ginboot-and-take-over-the-entrypoint]
```bash
go get -u github.com/klass-lk/ginboot
```
Replace the code that builds and runs your router. Keep everything else.
```go
// 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.
Your existing `http.Handler` — a `*http.ServeMux`, a `chi.Mux`, a `gorilla/mux` router —
can be mounted on the Gin engine while you convert routes off it:
```go
func main() {
server := ginboot.New()
legacy := buildLegacyMux() // returns http.Handler
// Everything not yet converted falls through to the old handler.
server.Engine().NoRoute(gin.WrapH(legacy))
log.Fatal(server.Start(8080))
}
```
`gin.WrapH` and `gin.WrapF` also work per route, if you would rather mount specific
prefixes than use `NoRoute`:
```go
server.Engine().Any("/legacy/*path", gin.WrapH(legacy))
```
Echo is an `http.Handler`, so it can be mounted the same way as `net/http` while you
convert:
```go
server.Engine().NoRoute(gin.WrapH(echoInstance))
```
Fiber is built on fasthttp and is **not** an `http.Handler`. There is no mounting trick:
run the Fiber app on a second port behind your load balancer and move paths across one
at a time, or convert all routes in one change if the service is small.
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:**
```bash
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` [#step-2--move-configuration-into-ginbootyml]
`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:
```yaml
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:
```go
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](/docs/2-core-concepts/configuration) for the full key list.
`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 [#step-3--convert-endpoints-into-controllers]
This is the bulk of the work, done one resource at a time.
### The shape of a controller [#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.
```go
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)
}
}
```
```go
server.RegisterController("/users", userController) // → /api/v1/users/...
```
### Translating handlers [#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 today | Ginboot signature |
| :-------------------------------------- | :--------------------------------------------- |
| Reads path/query params, no body | `func(ctx *ginboot.Context) (T, error)` |
| Binds a JSON body, nothing else | `func(req R) (T, error)` |
| Binds a body *and* needs params or auth | `func(ctx *ginboot.Context, req R) (T, error)` |
| Takes no input at all | `func() (T, error)` |
| Not converted yet | `func(c *gin.Context)` — accepted unchanged |
```go
// 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](#step-4--convert-errors-to-apierror).
```go
// Before
func (c *UserController) Create(ctx *gin.Context) {
var req CreateUserRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := c.users.Create(req)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusCreated, user)
}
// After
func (c *UserController) Create(req CreateUserRequest) (model.User, error) {
return c.users.Create(req)
}
```
Binding, validation and the 400 on malformed input are handled before your function is
called. Note the status change — see the callout on [status codes](#success-is-always-200)
if `201` is part of your contract.
```go
// Before: parsing page/size/sort by hand
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(ctx.DefaultQuery("size", "10"))
// After
func (c *UserController) List(ctx *ginboot.Context) (ginboot.PageResponse[model.User], error) {
return c.users.List(ctx.GetPageRequest())
}
```
`GetPageRequest` reads `page` (default `1`), `size` (default `10`) and `sort` (default
`_id,asc`, where `field,desc` maps to direction `-1`) from the query string.
### Success is always 200 [#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:
```go
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 [#middleware-and-the-auth-context]
Gin middleware keeps working unchanged, at three levels:
```go
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:
```go
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:
```go
auth, err := ctx.GetAuthContext()
if err != nil {
return nil, err
}
_ = auth.UserID
_ = auth.Roles
```
***
## Step 4 — Convert errors to `ApiError` [#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:
```go
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:
```json
{ "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](/docs/3-features/service-communication).
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 [#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:
| Backend | Module | Constructor |
| :--------- | :---------------------------------------- | :---------------------------------------------- |
| MongoDB | `github.com/klass-lk/ginboot/db/mongo` | `mongo.NewMongoRepository[T](db, "collection")` |
| SQL (GORM) | `github.com/klass-lk/ginboot/db/sql` | `sql.NewSQLRepository[T](db)` |
| DynamoDB | `github.com/klass-lk/ginboot/db/dynamodb` | `dynamodb.NewDynamoDBRepository[T](client)` |
| In-memory | `github.com/klass-lk/ginboot/db/inmemory` | `inmemory.NewInMemoryRepository[T]()` |
```bash
go get github.com/klass-lk/ginboot/db/mongo
```
Embed the repository to keep your custom queries next to the generated CRUD:
```go
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](/docs/3-features/database).
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 [#step-6--move-background-work]
| What you have | Ginboot equivalent |
| :------------------------------- | :---------------------------------------------------------------------------------------- |
| A goroutine with a `time.Ticker` | `server.RegisterWorker("name", 5*time.Minute, fn)` |
| A `robfig/cron` job | A type implementing `Worker` plus `Cron() string`, registered with `RegisterWorkerStruct` |
| An SQS/queue poller | `server.RegisterConsumer(ginboot.NewQueueConsumer("sms", ginboot.Queue("sms"), fn))` |
```go
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](/docs/3-features/workers) and
[Event Triggers](/docs/3-features/event-triggers).
***
## Step 7 — Turn on the platform features [#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](/docs/3-features/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](/docs/3-features/openapi-swagger).
* **Caching** — response caching with tag invalidation over DynamoDB, SQL or MongoDB. See
[Caching](/docs/3-features/caching).
* **AWS Lambda** — the same controllers behind API Gateway:
```go
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](/docs/3-features/aws-lambda).
***
## Coming from another router [#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.
| Framework | Mountable during migration | Handler conversion |
| :------------------------------------- | :---------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
| **Gin** | Yes — same engine | Optional, and per route |
| **net/http**, **Chi**, **gorilla/mux** | Yes, via `gin.WrapH` | `(w, r)` → `(ctx *ginboot.Context) (T, error)`; `r.URL.Query().Get` → `ctx.Query`; `mux.Vars(r)["id"]` → `ctx.Param("id")` |
| **Echo** | Yes, via `gin.WrapH` | `c.Bind(&req)` → a request parameter; `c.JSON(200, v)` → `return v, nil`; `echo.NewHTTPError(404, msg)` → `ginboot.NewApiError(404, msg)` |
| **Fiber** | No — fasthttp, not `net/http` | As 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 [#migration-checklist]
Work down this list per endpoint group; it is the same list the
[agent playbook](/docs/5-migration/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 [#common-pitfalls]
| Symptom | Cause | Fix |
| :---------------------------------------------------------------- | :------------------------------------------------ | :-------------------------------------------------------- |
| `panic: handler must return (response, error)` | A converted handler returns one value or three | Return exactly two values, the second an `error` |
| `panic: first argument must be *Context when using two arguments` | Two-argument handler with the request first | Order is always `(ctx *ginboot.Context, req R)` |
| `panic: handler must have 0-2 arguments` | Extra parameters on the handler | Read anything else off `ctx` |
| Routes answer at `/api/v1/api/v1/...` | Base path set *and* baked into route strings | Remove the prefix from the route strings |
| `Headers were already written` in the log | Handler wrote a response **and** returned a value | Return `nil, nil` after writing yourself |
| Every response is `200`, contract says `201` | Ginboot's success default | Write the status explicitly, return `nil, nil` |
| `401` on every protected route | Middleware does not set `user_id` and `role` | Set both keys with `c.Set` |
| Middleware never runs | Registered after the routes | `server.Engine().Use(...)` before registering controllers |
## Next steps [#next-steps]
# Porting an API from Another Language (/docs/5-migration/from-other-languages)
This guide is for replacing an API written in another language with a Ginboot service.
The old service keeps serving traffic the whole time; requests move across one path at a
time, and any path can be moved back.
If your API is already in Go, use
[Migrating an Existing Go App](/docs/5-migration/from-go) instead — that migration is
in-place and does not need a cutover.
## The principle: the contract is the specification [#the-principle-the-contract-is-the-specification]
You are not porting code. You are re-implementing a **contract** — a set of paths,
request shapes, response shapes and status codes — in a different language, and proving
the new implementation answers identically. Everything below follows from that.
A handler translated statement by statement from Express or Django carries that
framework's idioms into Go, and you end up maintaining a foreign design in an
unfamiliar language. Port the *behaviour* of each endpoint: its inputs, its rules, its
outputs, its failure modes.
***
## Concept mapping [#concept-mapping]
The framework concepts map closely enough that most teams can work from this table alone.
| Concept | Express / NestJS | FastAPI / Django | Spring Boot | Ginboot |
| :------------------- | :-------------------------- | :----------------------------- | :-------------------------------------- | :----------------------------------------------------- |
| App instance | `express()` / `NestFactory` | `FastAPI()` / WSGI app | `@SpringBootApplication` | `ginboot.New()` |
| Route grouping | `Router` / `@Controller` | `APIRouter` / `urls.py` | `@RestController` | A type with `Register(group *ginboot.ControllerGroup)` |
| Mounting a group | `app.use('/users', r)` | `include_router(prefix=)` | `@RequestMapping("/users")` | `server.RegisterController("/users", c)` |
| Handler | `(req, res) => {}` | `async def handler(...)` | `@GetMapping` method | `func(ctx, req) (T, error)` |
| Request validation | Joi / `class-validator` | Pydantic / serializers | Bean Validation | `binding` struct tags |
| Dependency injection | Nest providers | `Depends()` | `@Autowired` | Constructor parameters, wired in `main.go` |
| Data access | Prisma / TypeORM | SQLAlchemy / ORM | Spring Data `JpaRepository` | `GenericRepository[T]` |
| Error → status | `next(err)` + handler | `HTTPException` | `@ResponseStatus` / `@ControllerAdvice` | `return ginboot.NewApiError(404, "...")` |
| Config | `.env` + `process.env` | `settings.py` / `BaseSettings` | `application.yml` | `ginboot.yml` + `.env` |
| Middleware | `app.use(fn)` | Middleware classes | Filters / interceptors | `gin.HandlerFunc` |
| Auth context | `req.user` | `request.user` | `SecurityContextHolder` | `ctx.GetAuthContext()` |
| Background jobs | BullMQ / node-cron | Celery / APScheduler | `@Scheduled` | `RegisterWorker` / `RegisterConsumer` |
| Queue consumer | BullMQ worker | Celery task | `@SqsListener` | `ginboot.NewQueueConsumer` |
| API docs | Swagger decorators | Automatic OpenAPI | springdoc | Generated from controller types |
| Logging | Winston / Pino | `logging` | SLF4J | `ctx.Logger()` |
Rails and Laravel map onto the same rows: a controller is a controller, `strong parameters`
or a `FormRequest` is a request struct with `binding` tags, ActiveRecord or Eloquent is a
repository, `rescue_from`/`abort(404)` is an `ApiError`, and Sidekiq or Horizon jobs are
workers and consumers.
***
## The porting procedure [#the-porting-procedure]
### Capture the contract you must honour [#capture-the-contract-you-must-honour]
Before writing Go, produce a machine-readable description of the existing API. In order
of preference:
1. An OpenAPI/Swagger document the old service already generates.
2. Recorded traffic — a day of real requests and responses from the access log or proxy.
3. The route table plus the tests, read by hand.
You need, for every endpoint: method, path, path/query parameters, request body fields
with types and required-ness, response body shape, every status code it can return, and
what authentication it requires.
**Checkpoint:** a list of endpoints that you and the API's consumers agree is complete.
### Stand up the skeleton [#stand-up-the-skeleton]
```bash
go get -u github.com/klass-lk/ginboot
```
Use [start.ginboot.com](https://start.ginboot.com) to scaffold, or create the layout by
hand:
```go
package main
import (
"log"
"github.com/klass-lk/ginboot"
)
func main() {
server := ginboot.New()
cfg := server.Config()
server.SetBasePath(cfg.Ginboot.Server.BasePath)
// Controllers are registered here as you port them.
log.Fatal(server.Start(cfg.Ginboot.Server.Port))
}
```
**Checkpoint:** `curl localhost:8080/healthz` returns `{"status":"UP",...}`.
### Port the models [#port-the-models]
Translate each entity once, with the tags the target backend needs. Go's zero values are
the trap here: a missing JSON field and an explicitly-sent `0` or `""` are
indistinguishable unless you use a pointer.
```go
type User struct {
ID string `json:"id" bson:"_id" ginboot:"_id"`
Email string `json:"email" bson:"email"`
Name string `json:"name" bson:"name"`
Age *int `json:"age" bson:"age"` // nullable: pointer
CreatedAt time.Time `json:"createdAt" bson:"created_at"`
}
```
| Source type | Go type |
| :------------------------------ | :------------------------------------------------ |
| `string` | `string` |
| `number` / `int` / `Integer` | `int`, `int64` |
| `float` / `Double` / `Decimal` | `float64` — for money, prefer integer minor units |
| `boolean` | `bool` |
| `Date` / `datetime` / `Instant` | `time.Time` |
| nullable / `Optional` | `*T` |
| `array` / `List` | `[]T` |
| `object` / `Map` / `dict` | a struct, or `map[string]interface{}` |
| `enum` | a `string` type with constants |
| `UUID` | `string`, or `uuid.UUID` |
Go exports fields with capitals; your JSON is probably `camelCase` or `snake_case`. The
`json` tag decides what clients see, and a missing tag silently renames a field in your
public API. Tag every field.
**Checkpoint:** a round-trip test that unmarshals a recorded response body from the old
API into your struct, marshals it back, and compares.
### Port the business logic into services [#port-the-business-logic-into-services]
Services hold the rules and must not import the web framework. They take a
`context.Context` where the old code took a request or session, and they return domain
errors.
```go
type UserService struct {
repo *repository.UserRepository
}
func (s *UserService) Register(req model.RegisterRequest) (model.User, error) {
exists, err := s.repo.ExistsBy("email", req.Email)
if err != nil {
return model.User{}, err
}
if exists {
return model.User{}, ErrEmailTaken.New(req.Email)
}
// ... the same rules the old service had ...
return user, s.repo.Save(user)
}
```
This is the step where a port usually goes wrong: hidden behaviour. Implicit ORM
callbacks, request-scoped globals, Django signals, Rails `before_save` hooks and Spring
aspects do not exist in Go. Every one of them has to become an explicit call, and finding
them means reading the old code rather than the old routes.
**Checkpoint:** service-level unit tests, ported from the old test suite where one exists.
### Port the data layer [#port-the-data-layer]
Pick the repository module for your backend and point it at the **same database** the old
service uses, so the two implementations can run side by side:
```bash
go get github.com/klass-lk/ginboot/db/mongo # or /db/sql, /db/dynamodb
```
```go
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"),
}
}
```
| ORM idiom | Ginboot repository |
| :------------------------------------ | :------------------------------------------- |
| `findById` / `get(pk=)` / `findOne` | `FindById(id)` |
| `findAll` / `.all()` | `FindAll()` |
| `where({status})` / `filter(status=)` | `FindByFilters(map[string]interface{}{...})` |
| `count()` | `CountBy` / `CountByFilters` |
| `exists()` | `ExistsBy` / `ExistsByFilters` |
| `save` / `create` / `upsert` | `Save`, `SaveAll`, `SaveOrUpdate` |
| `update` | `Update` |
| `delete` / `destroy` | `Delete(id)` |
| `paginate(page, per_page)` | `FindAllPaginated(ginboot.PageRequest{...})` |
| A hand-written query | A method on your embedded repository type |
Two things do not come across, and pretending otherwise causes production surprises:
* **Lazy loading and eager relations.** There is no `include`, `select_related` or
`@OneToMany` fetch. Related data is a second call, or a field you store denormalised.
* **Schema migrations.** Ginboot does not manage schema. Keep the migration tool the old
stack used, or adopt one — but the schema stays owned by exactly one system.
**Checkpoint:** the repository reads real rows written by the old service and produces
identical values.
### Port the handlers [#port-the-handlers]
Handlers should be thin: bind, delegate, return. Ginboot binds and validates the request
before your function runs.
```javascript
// Before
router.post('/users', async (req, res) => {
const { email, name } = req.body;
if (!email) return res.status(400).json({ error: 'email required' });
try {
const user = await userService.register({ email, name });
res.status(201).json(user);
} catch (err) {
res.status(409).json({ error: err.message });
}
});
```
```go
// After
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Name string `json:"name" binding:"required"`
}
func (c *UserController) Register(group *ginboot.ControllerGroup) {
group.POST("", c.Create)
}
func (c *UserController) Create(req RegisterRequest) (model.User, error) {
return c.users.Register(req)
}
```
The `400` for a missing field comes from the `binding` tag. The `409` comes from the
service returning `ErrEmailTaken`.
```python
# Before
class RegisterRequest(BaseModel):
email: EmailStr
name: str
@router.post("/users", status_code=201)
async def register(req: RegisterRequest):
if await users.exists(req.email):
raise HTTPException(status_code=409, detail="Email already registered")
return await users.register(req)
```
```go
// After
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Name string `json:"name" binding:"required"`
}
var ErrEmailTaken = ginboot.NewApiError(409, "Email %s is already registered")
func (c *UserController) Create(req RegisterRequest) (model.User, error) {
return c.users.Register(req) // returns ErrEmailTaken.New(email)
}
```
Pydantic's field types become `binding` tags: `EmailStr` → `binding:"email"`,
`conint(ge=0)` → `binding:"gte=0"`, `Optional[T]` → `*T` with no `required`.
```java
// Before
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public User register(@Valid @RequestBody RegisterRequest req) {
return userService.register(req);
}
@GetMapping("/{id}")
public User get(@PathVariable String id) {
return userService.findById(id);
}
}
```
```go
// After
func (c *UserController) Register(group *ginboot.ControllerGroup) {
group.POST("", c.Create)
group.GET("/:id", c.Get)
}
func (c *UserController) Create(req RegisterRequest) (model.User, error) {
return c.users.Register(req)
}
func (c *UserController) Get(ctx *ginboot.Context) (model.User, error) {
return c.users.FindById(ctx.Param("id"))
}
```
`@Valid` becomes `binding` tags, `@PathVariable` becomes `ctx.Param`, and
`@ControllerAdvice` becomes `ApiError` values returned from services. There is no
component scanning — controllers are constructed and registered explicitly in `main.go`,
which is the whole of the dependency injection story.
```typescript
// Before
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Post()
@HttpCode(201)
register(@Body() dto: RegisterDto): Promise {
return this.users.register(dto);
}
}
```
```go
// After
type UserController struct {
users *service.UserService
}
func NewUserController(users *service.UserService) *UserController {
return &UserController{users: users}
}
func (c *UserController) Register(group *ginboot.ControllerGroup) {
group.POST("", c.Create)
}
func (c *UserController) Create(req RegisterRequest) (model.User, error) {
return c.users.Register(req)
}
```
Nest's module graph becomes plain constructor calls in `main.go`. Guards become
`gin.HandlerFunc` middleware; interceptors become middleware or telemetry.
Ginboot answers `200` on every success path. If the old API returned `201 Created` or
`204 No Content`, write it explicitly and return `nil, nil` — Ginboot will not override
a response that has already been written:
```go
ctx.JSON(http.StatusCreated, user)
return nil, nil
```
**Checkpoint:** for each ported endpoint, the same request returns the same body and
status as the old service.
### Port validation and errors [#port-validation-and-errors]
`binding` tags come from [go-playground/validator](https://github.com/go-playground/validator);
a failure becomes a `400` before your handler runs.
| Rule | Pydantic / class-validator / Bean Validation | Ginboot tag |
| :------------ | :------------------------------------------- | :-------------------------------- |
| Required | `Field(...)` / `@IsNotEmpty` / `@NotNull` | `binding:"required"` |
| Email | `EmailStr` / `@IsEmail` | `binding:"email"` |
| Length | `min_length` / `@Length` / `@Size` | `binding:"min=3,max=64"` |
| Numeric range | `ge` / `@Min` / `@Max` | `binding:"gte=0,lte=100"` |
| One of | `Literal[...]` / `@IsIn` | `binding:"oneof=draft published"` |
| URL | `HttpUrl` / `@IsUrl` | `binding:"url"` |
| Optional | `Optional[T]` / `@IsOptional` | pointer type, no `required` |
Declare the API's error vocabulary once, and give each error the status code the old API
used:
```go
var (
ErrUserNotFound = ginboot.NewApiError(404, "User with ID %s not found")
ErrEmailTaken = ginboot.NewApiError(409, "Email %s is already registered")
ErrForbidden = ginboot.NewApiError(403, "Operation not permitted")
)
```
Every response body then has the same shape, and an unmapped internal error becomes a
`500` rather than leaking:
```json
{ "error_code": "404", "message": "User with ID 42 not found" }
```
Clients parse error bodies. If the old API returned `{"error": "..."}` or
`{"detail": "..."}`, either keep the old shape with a middleware that rewrites the
response, or version the change and tell the clients. Do not discover it at cutover.
### Port authentication [#port-authentication]
Authentication is middleware. Whatever verifies the token must set two keys, because
`ctx.GetAuthContext()` reads exactly these:
```go
func Auth() gin.HandlerFunc {
return func(c *gin.Context) {
raw := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
token, err := ginboot.ParseAccessToken(raw)
if err != nil {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
claims, err := ginboot.ExtractClaims(token)
if err != nil || ginboot.IsExpired(claims) {
c.AbortWithStatus(http.StatusUnauthorized)
return
}
c.Set("user_id", ginboot.ExtractUserId(claims))
c.Set("role", ginboot.ExtractRole(claims))
c.Next()
}
}
```
```go
protected := group.Group("", middleware.Auth())
protected.GET("/me", c.Me)
```
Sessions are the one case that needs a decision rather than a translation. If the old API
authenticated with server-side sessions (Django, Rails, Laravel, Spring Session), you are
choosing between sharing the session store, or issuing JWTs and migrating clients. Decide
this before the cutover, not during it.
See [Authentication](/docs/3-features/authentication) for JWT helpers and PBKDF2 password
hashing — including how to keep verifying hashes produced by the old stack.
### Port scheduled jobs and queue consumers [#port-scheduled-jobs-and-queue-consumers]
| Old stack | Ginboot |
| :------------------------------------------------- | :------------------------------------------------------------------------------------ |
| `node-cron`, APScheduler, `@Scheduled`, `whenever` | `server.RegisterWorker(name, interval, fn)`, or a `Worker` with a `Cron()` expression |
| Celery, Sidekiq, BullMQ, Laravel queues | `server.RegisterConsumer(ginboot.NewQueueConsumer(...))` |
| A cron entry calling a management command | A worker, so it ships with the service |
```go
server.RegisterWorker("expire-sessions", time.Hour, func(ctx context.Context) error {
return svc.PurgeExpiredSessions(ctx)
})
server.RegisterConsumer(ginboot.NewQueueConsumer("sms", ginboot.Queue("sms"),
func(ctx context.Context, msg model.SMS) error {
return smsService.Send(ctx, msg)
}))
```
While both services run, a scheduled job must run in exactly one of them. Disable it in
the old service the moment you enable it in the new one — a nightly billing run that
fires twice is a worse outcome than a migration that takes a week longer.
See [Background Workers](/docs/3-features/workers) and
[Event Triggers](/docs/3-features/event-triggers).
### Cut traffic over, path by path [#cut-traffic-over-path-by-path]
Put both services behind the same proxy or load balancer, and move routes across
individually:
```text
┌──────────────┐
clients → │ proxy / ALB │
└──────┬───────┘
│ /api/v1/users/* → ginboot service (ported)
│ /api/v1/orders/* → ginboot service (ported)
│ /api/v1/* → legacy service (not yet)
```
A practical order:
1. Read-only endpoints first — a wrong response is recoverable, a wrong write is not.
2. Low-traffic write endpoints next.
3. The core write paths last, after the earlier ones have run for a while under real load.
Two techniques are worth the effort on any API that matters:
* **Shadow traffic.** Send a copy of production requests to the new service and diff the
responses, without returning them to clients. It finds the field you renamed by
accident and the timestamp format you changed.
* **Contract tests.** Write each endpoint's behaviour once in Gherkin and run it against
both services. Ginboot ships Godog step definitions for exactly this — see
[Testing & BDD](/docs/4-advanced/testing).
Keep the rollback simple: one proxy rule per path, so reverting is one change.
**Checkpoint:** the old service receives no traffic for a full business cycle — including
month-end, if that is a thing in your domain — before you delete it.
***
## Things that bite when porting to Go [#things-that-bite-when-porting-to-go]
| Coming from | The surprise |
| :--------------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
| Any dynamic language | Zero values. A missing `"age"` and `"age": 0` are the same `int`. Use `*int` when the difference matters. |
| Any dynamic language | JSON field names come from struct tags, not field names. An untagged field is `PascalCase` in the response. |
| JavaScript | No `undefined` vs `null`. Model it with a pointer, or a wrapper type if you need all three states. |
| Python, Ruby | No keyword or default arguments. Use an options struct. |
| Java, C# | No exceptions. Every failure is a returned `error` you must handle or return. |
| Java, C# | No component scanning or DI container. Wiring is explicit in `main.go`. |
| Node.js | Handlers run concurrently on real threads. Shared mutable state needs a mutex; there is no single-threaded event loop protecting you. |
| Django, Rails, Laravel | No ORM lifecycle hooks, no signals, no `before_save`. Make every one explicit. |
| Rails, Laravel | No convention-based routing. Routes are declared in `Register`. |
| Everywhere | Decimal money. `float64` is not exact — use integer minor units or a decimal type. |
| Everywhere | Time zones. `time.Time` carries a location; decide on UTC at the boundary and stay there. |
## Parity checklist [#parity-checklist]
Before the last path moves across:
* [ ] Every endpoint from the captured contract exists, with the same method and path.
* [ ] Request field names, types and required-ness match, including optional fields.
* [ ] Response bodies are byte-comparable on the happy path — field names, casing, date
format, number precision, null vs omitted.
* [ ] Every status code the old API could return is reachable in the new one.
* [ ] Error body shape matches, or clients have been migrated to the new one.
* [ ] Authentication accepts the tokens or sessions clients already hold.
* [ ] Pagination parameters and response envelope match.
* [ ] Rate limits, CORS origins and timeouts match.
* [ ] Every scheduled job and queue consumer runs in exactly one service.
* [ ] Logs, metrics and traces reach the same places, and alerts point at the new service.
* [ ] The exported OpenAPI spec (`GINBOOT_EXPORT_SWAGGER=openapi.json go run .`) matches
the contract captured in step 1.
## Next steps [#next-steps]
# Migration Overview (/docs/5-migration)
This section is about moving an API you already have onto Ginboot. It covers two
different problems that people often confuse:
## The one thing that decides the strategy [#the-one-thing-that-decides-the-strategy]
**Ginboot is Gin underneath, and it does not hide it.** `server.Engine()` returns the
plain `*gin.Engine`, and a handler with Gin's own `func(c *gin.Context)` signature can be
registered on a Ginboot route group unchanged.
That single fact splits the work in two:
| Your API today | Strategy | Downtime | Guide |
| :------------------ | :------------------------------------------------------------------------------------------------------------------- | :-------------------- | :------------------------------------------------------------- |
| Go + Gin | **Incremental.** Swap the entrypoint, keep every route, convert one controller at a time. | None | [From Go](/docs/5-migration/from-go) |
| Go + another router | **Incremental, with rewritten handlers.** Routing and middleware are rewritten; services, models and tests are kept. | None | [From Go](/docs/5-migration/from-go) |
| Another language | **Port + cutover.** New service, same contract, traffic moved path by path. | None, if you run both | [From other languages](/docs/5-migration/from-other-languages) |
The most common way a migration fails is starting a new project, porting everything, and
trying to switch in one step. Both guides here are built so that the application is
running and serving traffic at the end of *every* step, not just the last one.
## What Ginboot replaces, and what it leaves alone [#what-ginboot-replaces-and-what-it-leaves-alone]
Migrating is mostly *deleting* code you wrote to fill gaps the framework now fills.
## The phases, in the order that keeps you safe [#the-phases-in-the-order-that-keeps-you-safe]
Both guides follow the same five phases. Do them in this order — each one is a
verifiable checkpoint, and skipping ahead is what turns a migration into a rewrite.
### Take over the entrypoint [#take-over-the-entrypoint]
`ginboot.New()` becomes the thing that owns the process. Existing routes are mounted
as-is on `server.Engine()`. Nothing else changes yet.
**Verify:** the app builds, starts, and every existing endpoint answers exactly as before.
### Move configuration in [#move-configuration-in]
Introduce `ginboot.yml` and read settings through `server.Config()` instead of scattered
`os.Getenv` calls. Keep the environment variable names you already deploy with — the
`${VAR:default}` syntax exists so the values still come from the platform.
**Verify:** the app starts with your real environment, and with an empty one falls back
to the documented defaults.
### Convert one endpoint group at a time [#convert-one-endpoint-group-at-a-time]
Pick the least critical resource. Turn it into a controller with `Register(group *ginboot.ControllerGroup)`,
convert its handlers to `(T, error)`, and register it with `server.RegisterController`.
Ship it. Then take the next one.
**Verify:** the converted routes return byte-identical responses. A response diff test
against the old implementation is worth writing once and running for every group.
### Move the data access layer [#move-the-data-access-layer]
Replace hand-written CRUD with a repository. This is where most of the deleted lines
come from, and it is deliberately after the routing work: a repository swap under a
converted controller is a small, contained change.
**Verify:** the repository's own tests pass, then the endpoint tests still pass.
### Turn on the platform features [#turn-on-the-platform-features]
Telemetry, caching, the OpenAPI endpoint, background workers, queue consumers, Lambda.
These are additive and independent — take them in whatever order your operations need.
**Verify:** `/healthz` answers, traces arrive at your collector, and the exported
OpenAPI spec matches the contract your clients hold.
## Definition of done [#definition-of-done]
A migration is finished when all of these are true. Until then it is in progress, which
is a fine state to be in — but it is not finished.
* `go build ./...` and `go test ./...` pass.
* Every route is registered inside a controller, not on `server.Engine()` directly.
* No handler writes a response body and returns a value; it does one or the other.
* Every error path returns a `ginboot.ApiError` with the status code the old API used.
* Configuration comes from `ginboot.yml` and the environment, never from a literal in code.
* The exported OpenAPI spec matches the contract the clients were built against.
* The old routing, binding, error-formatting and config-loading code is **deleted**, not
left behind unused.
## For AI coding agents [#for-ai-coding-agents]
This documentation is published at [ginboot.com](https://ginboot.com) in an
agent-readable form:
* **[/llms.txt](https://ginboot.com/llms.txt)** — an index of every documentation page.
* **[/llms-full.txt](https://ginboot.com/llms-full.txt)** — the full text of the entire
documentation in one request.
* **`/llms.mdx/docs//content.md`** — the Markdown source of any single page. For
this page: [`/llms.mdx/docs/5-migration/content.md`](https://ginboot.com/llms.mdx/docs/5-migration/content.md).
If you are an agent performing a migration, read the
**[Migration Playbook for Coding Agents](/docs/5-migration/agent-playbook)** first. It
states the exact API surface, the step order, the verification command after each step,
and the mistakes that are most often made from stale training data.