Getting Started with NestGo: A NestJS-Style Web Framework for Go
This NestGo tutorial walks you through building your first application with NestGo, the NestJS-for-Go web framework. In a few minutes you will install the core module, choose an HTTP adapter, and run a working REST API in Golang.
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Go | 1.25.14 or later | Older 1.25.x toolchains are upgraded automatically via the go directive. The patch level matters — it includes Go standard-library security fixes. |
| An HTTP adapter | Gin or Fiber | Separate modules; you install exactly one. |
Check your Go version:
go version
How to install NestGo
Create a new module and install the NestGo core:
mkdir myapp && cd myapp
go mod init myapp
go get github.com/ashrafAli23/nestgo
Choose one adapter
NestGo’s core is interfaces only — an adapter plugs in the actual HTTP engine. Install one of the following (they are separate Go modules):
# Option A: Gin
go get github.com/ashrafAli23/nestgo-gin-adapter
# Option B: Fiber
go get github.com/ashrafAli23/nestgo-fiber-adapter
You can swap adapters later by changing a single line in main.go — handlers, guards, interceptors, pipes, and middleware work identically on both.
Quick start: your first NestGo app
Create main.go with a config and a controller. This is a complete, runnable Go web application — no DI container required:
package main
import (
"context"
"log"
"github.com/ashrafAli23/nestgo/app"
"github.com/ashrafAli23/nestgo/core"
"github.com/ashrafAli23/nestgo/middleware"
ginadapter "github.com/ashrafAli23/nestgo-gin-adapter"
)
// ─── DTO ────────────────────────────────────────────────────────────────────
type CreateUserDTO struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (d *CreateUserDTO) Validate() error {
if d.Name == "" {
return core.ErrBadRequest("name is required")
}
return nil
}
// ─── Controller ─────────────────────────────────────────────────────────────
type UserController struct{}
func NewUserController() *UserController { return &UserController{} }
func (c *UserController) Prefix() string { return "/users" }
func (c *UserController) RegisterRoutes(r core.Router) {
r.GET("/", core.Handle1(core.Q("search", ""), c.List))
r.GET("/:id", core.Handle2(core.RCtx(), core.PInt64("id"), c.GetByID))
r.POST("/", core.HandleC1(core.B[CreateUserDTO](), c.Create))
}
func (c *UserController) List(search string) (any, error) {
users := []map[string]any{
{"name": "John"},
{"name": "Jane"},
}
return users, nil
}
func (c *UserController) GetByID(ctx context.Context, id int64) (any, error) {
// ctx carries request cancellation — pass it to DB, gRPC, HTTP calls
return map[string]any{"id": id, "name": "John"}, nil
}
func (c *UserController) Create(ctx core.Context, dto *CreateUserDTO) error {
return ctx.JSON(201, map[string]any{"name": dto.Name, "email": dto.Email})
}
// ─── Main ───────────────────────────────────────────────────────────────────
func main() {
cfg := core.DefaultConfig()
cfg.Addr = ":3000"
cfg.GlobalPrefix = "/api"
a := app.New(cfg, ginadapter.New)
a.Use(middleware.Recovery(), middleware.Logger(), middleware.RequestID())
a.Register(NewUserController())
if err := a.Run(); err != nil {
log.Fatal(err)
}
}
Using Fiber instead? Change one line:
import fiberadapter "github.com/ashrafAli23/nestgo-fiber-adapter"
a := app.New(cfg, fiberadapter.New)
With dependency injection
Prefer a DI container? Swap the main above for this one — same DTO, same controller, everything else on this page stays identical:
func main() {
config := core.DefaultConfig()
config.Addr = ":3000"
config.GlobalPrefix = "/api"
app := di.NewApp(config, ginadapter.New,
di.Invoke(func(server core.Server) {
server.Use(middleware.Recovery(), middleware.Logger(), middleware.RequestID())
}),
di.Module("users", di.Controller(NewUserController)),
)
app.Run()
}
This variant imports "github.com/ashrafAli23/nestgo/di" instead of app; no fx import needed.
Which should I use?
Both bootstraps behave identically — same controllers, same middleware ordering, same lifecycle hooks, same shutdown sequence. Reach for app for a small service, or when you’d rather not learn a DI container. Reach for di once you have many modules with deep constructor graphs, or want NestJS-style modules and providers. See Application Without DI and Dependency Injection for the full comparison.
What each part does
Config
core.DefaultConfig() returns sensible defaults: Addr: ":8080", a 4 MB BodyLimit, 10-second read/write timeouts, and the default error handler. The example overrides two fields:
| Field | Effect |
|---|---|
Addr | The listen address — :3000 here. |
GlobalPrefix | Prepended to every route, so the controller’s /users routes become /api/users. |
Config also carries global guards, pipes, interceptors, filters, TLS files, health checks, and graceful-shutdown hooks — see the Production Guide for the full core.Config field reference.
Controller
A controller is any struct implementing RegisterRoutes(router core.Router). Two optional interfaces add behavior automatically:
Prefix() string— NestGo detectsPrefixedControllerand groups all routes under the prefix (/usershere).Version() string—VersionedControlleropts the controller into API versioning.
Inside RegisterRoutes, the handle builders (Handle1, Handle2, HandleC1, …) combine type-safe extractors with plain Go functions:
core.Q("search", "")— query parameter with a default (NestJS@Query()).core.PInt64("id")— path parameter parsed asint64, ideal for database IDs.core.RCtx()— the request’scontext.Context, so services see cancellation and timeouts.core.B[CreateUserDTO]()— parses the JSON body into the DTO and calls itsValidate()method (NestJS@Body()+ValidationPipe).
Handle1/Handle2 auto-respond with JSON 200 from your function’s return value. HandleC1 passes core.Context first, giving you full response control — the example uses it to return 201 Created.
di.NewApp and app.Run()
di.NewApp(config, provider, opts...) builds an uber/fx application, using the di wrappers so nothing in your code has to import go.uber.org/fx directly:
- Provides the
*core.Configand creates thecore.Serverfrom your adapter’s constructor (ginadapter.Neworfiberadapter.New). - Derives the root
core.Router, applyingGlobalPrefix, global middleware, and global guards/pipes/interceptors/filters from the config. - Applies your
di.Options — middleware setup viadi.Invoke, feature modules viadi.Module. These run before routes are registered, which matters on both adapters: middleware chains are composed when each route is registered, soUse()after registration does not affect that route. Rawfx.Options (fx.Provide,fx.Invoke,fx.Annotate, …) still work anywhere adi.Optionis expected —di.Optionis just a type alias forfx.Option. - Appends
di.CoreModule, which registers health endpoints, wires everydi.Controller(...)constructor into the router, hooks the server into the fx lifecycle, and registersOnModuleInit/OnModuleDestroyhooks.
app.Run() starts the server and blocks. SIGINT/SIGTERM triggers graceful shutdown: in-flight requests drain for up to config.ShutdownTimeout (default 10 seconds), then your config.OnShutdown hooks run. If the server fails to start (port in use, bad certificate), the app shuts down with exit code 1 instead of hanging without a listener.
di.Controller(NewUserController) registers the constructor so the container collects it into the controller group — the NestGo equivalent of listing a controller in a NestJS @Module(). See Dependency Injection.
Run and test the API
Start the app:
go run .
Then exercise the three routes with curl:
# List users (query extractor with default)
curl http://localhost:3000/api/users
# → [{"name":"John"},{"name":"Jane"}]
# Get a user by int64 ID
curl http://localhost:3000/api/users/42
# → {"id":42,"name":"John"}
# Create a user (201 Created)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Ada","email":"ada@example.com"}'
# → {"email":"ada@example.com","name":"Ada"}
# Validation failure — Validate() returns core.ErrBadRequest
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"email":"no-name@example.com"}'
# → 400 Bad Request: name is required
Project layout for a real Go application
For anything beyond a demo, organize your Golang project by feature, with one fx module per feature — the same mental model as NestJS modules:
myapp/
├── go.mod
├── main.go # config + adapter + module list
└── internal/
├── users/
│ ├── module.go # var Module = di.Module("users", ...)
│ ├── controller.go # routes + handlers
│ ├── service.go # business logic
│ └── dto.go # request/response DTOs
└── orders/
├── module.go
├── controller.go
└── service.go
Each feature exports a single fx.Option:
// internal/users/module.go
package users
import (
"github.com/ashrafAli23/nestgo/di"
)
var Module = di.Module("users",
di.Service(NewUserService), // injected into the controller
di.Controller(NewUserController),
)
The controller receives its dependencies through its constructor:
// internal/users/controller.go
type UserController struct {
service *UserService
}
func NewUserController(service *UserService) *UserController {
return &UserController{service: service}
}
And main.go stays a thin composition root:
func main() {
config := core.DefaultConfig()
config.Addr = ":3000"
config.GlobalPrefix = "/api"
config.HealthCheck = true // GET /health for K8s liveness probes
app := di.NewApp(config, ginadapter.New,
users.Module,
orders.Module,
)
app.Run()
}
Adding a feature means adding a directory and one line in main.go — controllers register their own routes, and the DI container wires everything together. Browse the full source and more examples at github.com/ashrafAli23/nestgo.
Next steps
- Controllers & Routing — routing, prefixes, extractors, and handle builders in depth.
- Dependency Injection — modules, providers, and lifecycle hooks with uber/fx.
- Middleware — the built-in CORS, Helmet, rate limiting, and logging stack.