Controllers and REST API Routing in Go

NestGo gives you NestJS-style controllers in Go: small structs that declare their routes, get auto-registered through dependency injection, and stay decoupled from the underlying HTTP engine (Gin or Fiber). This page covers the controller interfaces, every router method, route groups with middleware, and route metadata.

What is a controller in NestGo?

A controller is any type that implements the Controller interface from nestgo/core:

type Controller interface {
    RegisterRoutes(router Router)
}

That is the entire contract. The DI system collects every registered controller and calls RegisterRoutes on each one at startup, passing in a core.Router. Your controller never imports Gin or Fiber — it only talks to the core.Router and core.Context abstractions.

A minimal controller looks like this:

package user

import "github.com/ashrafAli23/nestgo/core"

type UserController struct {
    service *UserService
}

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

func (c *UserController) RegisterRoutes(r core.Router) {
    r.GET("/users", c.List)
    r.POST("/users", c.Create)
}

func (c *UserController) List(ctx core.Context) error {
    return ctx.JSON(200, []string{"John", "Jane"})
}

func (c *UserController) Create(ctx core.Context) error {
    return ctx.JSON(201, map[string]string{"status": "created"})
}

Handlers use the universal signature func(core.Context) error. For typed parameters extracted from the request (path params, body DTOs, query strings), see Type-Safe Handlers.

How to add a route prefix with PrefixedController

Instead of repeating /users on every route, implement the optional PrefixedController interface:

type PrefixedController interface {
    Prefix() string
}

When a controller implements it, the DI system automatically wraps the router in a Group with that prefix before calling RegisterRoutes:

func (c *UserController) Prefix() string { return "/users" }

func (c *UserController) RegisterRoutes(r core.Router) {
    r.GET("/", c.List)       // GET  /users/
    r.GET("/:id", c.Get)     // GET  /users/:id
    r.POST("/", c.Create)    // POST /users/
}

Combined with Config.GlobalPrefix (e.g. /api), the final paths become /api/users/....

How to version an API controller in Go

Implement the optional VersionedController interface and set Config.Versioning:

type VersionedController interface {
    Version() string
}
func (c *UserControllerV2) Version() string { return "2" }

How the version is applied depends on the configured strategy:

Strategy Config Effect
URI core.URIVersioning Routes are wrapped in a /v2 group → /v2/users
Header core.HeaderVersioning A VersionGuard checks the version header (e.g. Accept-Version: 2)
Media Type core.MediaTypeVersioning A VersionGuard checks the Accept media type (e.g. application/vnd.api.v2+json)
config := core.DefaultConfig()
config.Versioning = &core.VersioningConfig{Strategy: core.URIVersioning}

If Config.Versioning is nil, the Version() method is ignored.

Router methods reference

The core.Router interface defines all route registration. Every method takes a path, a core.HandlerFunc, and optional per-route middleware:

type Router interface {
    GET(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    POST(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    PUT(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    DELETE(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    PATCH(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    OPTIONS(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    HEAD(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    ANY(path string, handler HandlerFunc, middleware ...MiddlewareFunc)
    Group(prefix string, middleware ...MiddlewareFunc) Router
    Use(middleware ...MiddlewareFunc)
    Static(path string, root string, middleware ...MiddlewareFunc)
    StaticFile(path string, filePath string, middleware ...MiddlewareFunc)
}
Method Purpose
GET / POST / PUT / DELETE / PATCH / OPTIONS / HEAD Register a handler for one HTTP method
ANY Register one handler for all HTTP methods on a path
Group Create a sub-router with a path prefix and optional shared middleware
Use Attach middleware to this router (and everything registered after it)
Static Serve a directory of static files under a path
StaticFile Serve a single file at a path

Path parameters use the :name syntax (/users/:id), and both the Gin and Fiber adapters resolve them identically through core.Context.Param.

r.GET("/:id", c.Get)             // GET    /users/42
r.PUT("/:id", c.Update)          // PUT    /users/42
r.DELETE("/:id", c.Delete)       // DELETE /users/42
r.ANY("/webhook", c.Webhook)     // any method on /users/webhook
r.Static("/assets", "./public")  // serve ./public under /users/assets

Route groups in Go with shared middleware

Group returns a new Router scoped to a prefix. Middleware passed to the group wraps every route registered on it — this is how you apply guards or interceptors to a whole section of your API:

func (c *AdminController) RegisterRoutes(r core.Router) {
    // Everything under /admin requires the auth guard
    admin := r.Group("/admin", core.UseGuards(authGuard))

    admin.GET("/stats", c.Stats)
    admin.DELETE("/users/:id", c.DeleteUser)

    // Groups nest — /admin/audit with an extra interceptor
    audit := admin.Group("/audit", core.UseInterceptors(logInterceptor))
    audit.GET("/", c.AuditLog)
}

Per-route middleware composes with group middleware — the variadic argument on each route method applies only to that route:

r.DELETE("/:id", c.Delete, core.UseGuards(ownerGuard))

To bundle guards, pipes, interceptors, and exception filters in the correct NestJS execution order (Filters → Guards → Pipes → Interceptors), use core.ApplyRouteOptions:

opts := core.ApplyRouteOptions(core.RouteOptions{
    Guards:       []core.Guard{authGuard},
    Interceptors: []core.Interceptor{timing},
})
users := r.Group("/users", opts...)

See Guards for the full guard, interceptor, pipe, and filter model.

Route metadata with WithMeta and Meta

NestJS uses @SetMetadata() to attach data to routes for guards to read. NestGo does the same with core.WithMeta (single key) and core.Meta (multiple keys) — both are ordinary middleware that store values in the request context:

// Single key-value pair:
r.DELETE("/:id", handler,
    core.WithMeta("roles", []string{"admin"}),
    core.UseGuards(roleGuard),
)

// Multiple pairs at once:
r.GET("/admin", handler, core.Meta(map[string]interface{}{
    "roles":      []string{"admin"},
    "permission": "users:delete",
}))

Guards and interceptors read the metadata via Context.Get:

roleGuard := core.GuardFunc(func(c core.Context) (bool, error) {
    roles := c.Get("roles") // nil if not set on this route
    if roles == nil {
        return true, nil // route has no role requirement
    }
    // compare against the authenticated user's roles...
    return true, nil
})

Order matters: place WithMeta/Meta before the guard middleware that reads it, so the value is already in the context when the guard runs.

How di.RegisterControllers wires everything together

Controllers are collected and registered through the nestgo/di package (built on uber/fx). You tag each constructor with di.AsController:

fx.Module("users",
    fx.Provide(di.AsController(NewUserController)),
)

AsController annotates the constructor so its result is provided as a core.Controller in the fx value group "controllers". At startup, di.RegisterControllers receives the whole group and wires each controller to the server router:

  1. Versioning — if the controller implements VersionedController and Config.Versioning is set, the router is wrapped in a /v<version> group (URI strategy) or a group with a VersionGuard (Header / Media Type strategies).
  2. Prefix — if the controller implements PrefixedController, the router is wrapped in a group with Prefix().
  3. Routes — RegisterRoutes is called with the resulting router.

RegisterControllers runs as part of di.CoreModule, which di.NewApp appends automatically — so a complete app is just:

func main() {
    config := core.DefaultConfig()
    config.Addr = ":3000"
    config.GlobalPrefix = "/api"

    app := di.NewApp(config, ginadapter.New,
        fx.Module("users",
            fx.Provide(di.AsController(NewUserController)),
        ),
    )
    app.Run()
}

di.NewApp also applies Config.GlobalPrefix, global middleware, and global guards/pipes/interceptors/filters to the root router before any controller registers — your modules and middleware options are wired in before CoreModule adds the routes. The full source is at github.com/ashrafAli23/nestgo.

Next steps

  • Type-Safe Handlers — replace func(core.Context) error with typed handlers and extractors
  • Guards — protect routes and read the metadata you attached with WithMeta
  • Getting Started — full project setup with an adapter and DI modules