gocontroller is a lightweight Go library for building APIs with a familiar controller/module pattern inspired by NestJS and Express, while staying idiomatic Go.
It gives you:
- Controller-oriented route organization
- Route, group, and module middleware composition
- Nest-style module graph (
imports,providers,controllers) - Reflection-based dependency injection
- DTO-style request parsing and validation
- Annotation-driven route metadata with
go generate net/httpcompatibility so you can mount into Gin, Echo, Fiber adapters
Go frameworks are powerful, but many teams want a predictable architecture where:
- routes are declared near controller methods
- dependencies are constructor-injected
- feature modules are explicit
- request DTOs are validated consistently
gocontroller focuses on architecture and composition so your business code stays clean.
go get github.com/emmybxt/go-controllerpackage main
import (
"net/http"
"github.com/emmybxt/go-controller/gocontroller"
)
type HealthController struct{}
func (c *HealthController) RegisterRoutes(r *gocontroller.RouteGroup) {
r.GET("/health", c.Health)
}
func (c *HealthController) Health(ctx *gocontroller.Context) error {
return ctx.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
func NewHealthController() *HealthController { return &HealthController{} }
func main() {
app, err := gocontroller.NewApp(&gocontroller.Module{
Name: "AppModule",
Prefix: "/api",
Controllers: []any{NewHealthController},
})
if err != nil {
panic(err)
}
_ = app.Listen(":8080")
}Two supported styles:
- Classic interface style:
func (c *UserController) RegisterRoutes(r *gocontroller.RouteGroup) {
r.GET("/users/:id", c.GetByID)
r.POST("/users", c.Create, AuthMiddleware())
}- Metadata style:
func (c *UserController) ControllerMetadata() gocontroller.ControllerMetadata {
return gocontroller.ControllerMetadata{
Prefix: "/users",
Routes: []gocontroller.RouteMetadata{
gocontroller.GET("/:id", "GetByID"),
gocontroller.POST("/", "Create", AuthMiddleware()),
},
}
}Modules let you group providers/controllers and compose feature boundaries.
userModule := &gocontroller.Module{
Name: "UserModule",
Prefix: "/users",
Providers: []any{NewUserService, NewUserRepo},
Controllers: []any{NewUserController},
}
appModule := &gocontroller.Module{
Name: "AppModule",
Prefix: "/api",
Imports: []*gocontroller.Module{userModule},
}Register providers as:
- concrete instances
- constructor functions returning
T - constructor functions returning
(T, error)
Dependencies are resolved recursively from constructor parameters.
func NewUserService(repo *UserRepo) *UserService { ... }
func NewUserController(svc *UserService) *UserController { ... }Use ParseDTO[T] or ctx.BindJSON(&dto).
type CreateUserDTO struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
}
func (c *UserController) Create(ctx *gocontroller.Context) error {
dto, err := gocontroller.ParseDTO[CreateUserDTO](ctx)
if err != nil {
return err // handled as 400 when validation fails
}
return ctx.JSON(http.StatusCreated, dto)
}Validation is powered by go-playground/validator/v10, so you can use its broad built-in tag set (for the pinned version in this module), including tags like:
required,min,max,lenemail,url,uri,hostnameuuid,uuid4,ip,ipv4,ipv6oneof,startswith,endswith,containsgt,gte,lt,ltedatetimedivefor slices/maps
You can combine tags exactly as in validator syntax, e.g. validate:"required,oneof=admin user,lowercase".
Validation is fully swappable via gocontroller.Validator.
type Validator interface {
Validate(any) error
}Per-app override:
app.SetValidator(myValidator)Global default override:
gocontroller.SetDefaultValidator(myValidator)Function adapter:
app.SetValidator(gocontroller.ValidatorFunc(func(v any) error {
// call ozzo/json-schema/custom rules
return nil
}))Default engine is go-playground/validator/v10 wrapped by NewGoPlaygroundValidator().
Attach middleware at multiple levels:
- app/router (global)
- module
- route group
- route
r.POST("/users", c.Create, AuthMiddleware(), AuditMiddleware())Middleware signature:
type Middleware func(HandlerFunc) HandlerFuncBuilt-in helpers:
gocontroller.RequestLogger()gocontroller.AdaptHTTPMiddleware(func(http.Handler) http.Handler)gocontroller.RequestID()gocontroller.Recovery(gocontroller.RecoveryConfig{...})gocontroller.CORS(gocontroller.CORSConfig{...})gocontroller.SecurityHeaders()gocontroller.RequireContextValue(key, "Unauthorized")
If you prefer Nest-like annotations, use comments + generator.
// @Controller("/users")
type UserController struct{}
// @Get("/:id")
func (c *UserController) GetByID(ctx *gocontroller.Context) error { return nil }
// @Post("/")
// @Use(AuthMiddleware())
func (c *UserController) Create(ctx *gocontroller.Context) error { return nil }//go:generate go run ../cmd/gocontroller-gen -dir . -out zz_gocontroller_routes.gen.gogo generate ./exampleGenerated metadata is auto-registered through init() and picked up by the module loader.
go build does not run go generate automatically in Go.
Use build wrappers that always generate first:
make build # runs go generate ./... then go build ./...
make test # runs go generate ./... then go test ./...And enforce freshness in CI:
make verify-generatedYes, it can be used with those frameworks.
gocontroller exposes App.Handler() http.Handler, so you can mount it where wrappers are available.
import "github.com/gin-gonic/gin"
ginEngine := gin.Default()
ginEngine.Any("/api/*any", gin.WrapH(app.Handler()))import "github.com/labstack/echo/v4"
e := echo.New()
e.Any("/api/*", echo.WrapHandler(app.Handler()))import (
"github.com/gofiber/adaptor/v2"
"github.com/gofiber/fiber/v2"
)
f := fiber.New()
f.All("/api/*", adaptor.HTTPHandler(app.Handler()))Notes:
- This keeps your controller/module architecture in one place.
- If you need deep native middleware/context features of each framework, use adapters selectively at the boundary.
You can avoid manual finalHandler path-switch logic with:
gocontroller.WebAPIHandler(webHandler, apiHandler, opts)gocontroller.NotFoundHTMLOrJSON(html404Path, jsonMessage)gocontroller.ServePage(publicDir, pageFile)
Example:
final := gocontroller.WebAPIHandler(webRouter, app.Handler(), gocontroller.HybridOptions{
WebExactPaths: []string{"/"},
WebPathPrefixes: []string{"/app", "/css/", "/js/"},
TreatSingleSegmentGETAsWeb: true,
})Built-in response shortcuts on *gocontroller.Context:
ctx.OK(data)ctx.Created(data)ctx.NoContent()ctx.BadRequest(msg)ctx.Unauthorized(msg)ctx.Forbidden(msg)ctx.NotFound(msg)ctx.Conflict(msg)ctx.InternalError(msg)ctx.Success(status, data)andctx.Fail(status, msg)for envelope style
- Automatic
405 Method Not Allowed+Allowheader when path exists but method does not. - Wildcard routes with trailing
*.
router.GET("/assets/*", func(ctx *gocontroller.Context) error {
return ctx.OK(map[string]string{"path": ctx.Param("*")})
})Use gocontroller.APIError for consistent API error responses:
return &gocontroller.APIError{
StatusCode: 422,
Code: "validation_failed",
Message: "Invalid input",
Details: map[string]any{"field": "email"},
}Response shape:
{
"success": false,
"error": {
"code": "validation_failed",
"message": "Invalid input",
"details": {"field": "email"},
"trace_id": "..."
}
}Built-in helpers:
gocontroller.NewAPIError(status, code, message)gocontroller.BadRequestError(...)gocontroller.UnauthorizedError(...)gocontroller.ForbiddenError(...)gocontroller.NotFoundError(...)gocontroller.ConflictError(...)gocontroller.InternalError(...)
You can also override global route error rendering:
app.SetErrorHandler(func(ctx *gocontroller.Context, err error) {
_ = ctx.JSON(418, map[string]any{"custom": true, "error": err.Error()})
})Built-ins to reduce repeated auth glue:
RequireContextValue(key, message)middlewareContextValue[T](ctx, key)typed extractionMustContextValue[T](ctx, key, message)typed extraction with unauthorized error fallback
You can run server lifecycle with context-aware graceful shutdown:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
err := app.Run(ctx, gocontroller.ServerOptions{
Addr: ":8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
ShutdownTimeout: 10 * time.Second,
})Main types/functions:
gocontroller.NewApp(*Module)(*App).Listen(addr)(*App).Handler()(*App).SetValidator(v)/(*App).Validator()(*App).Run(ctx, ServerOptions)(*App).NewHTTPServer(ServerOptions)(*App).SetErrorHandler(ErrorHandlerFunc)Module{ Name, Prefix, Providers, Controllers, Imports, Middleware }RouteGroup.GET/POST/PUT/DELETEParseDTO[T](ctx)NewHTTPError(status, message)ControllerMetadata,RouteMetadataGET/POST/PUT/DELETEmetadata helpersRegisterGeneratedControllerMetadata(used by generated code)Validator,ValidatorFunc,SetDefaultValidator,DefaultValidatorAPIError,NewAPIError, helper constructorsRequestID(),Recovery(RecoveryConfig{})CORS(CORSConfig{}),SecurityHeaders()RequireContextValue(...),ContextValue[T](...),MustContextValue[T](...)
Default behavior:
- route not found:
404 - validation error:
400 - explicit
NewHTTPError(...): mapped status - unknown handler error:
500
Before publishing:
- Update module path in
go.modto your GitHub repo. - Add semantic tags (
v0.1.0,v0.2.0, etc.). - Add CI (
go test ./...,go vet ./...). - Add changelog and license.
- Add examples for both classic and annotation styles.
This project is licensed under the MIT License. See LICENSE.