Running NestGo Without Dependency Injection
Dependency injection is optional in NestGo. The app package runs a complete application — controllers, guards, interceptors, pipes, exception filters, middleware, API versioning, health probes, lifecycle hooks, and graceful shutdown — with plain Go constructors and no DI container. Nothing from go.uber.org/fx is linked into your binary unless you import the di package.
The whole thing
package main
import (
"log"
ginadapter "github.com/ashrafAli23/nestgo-gin-adapter"
"github.com/ashrafAli23/nestgo/app"
"github.com/ashrafAli23/nestgo/core"
"github.com/ashrafAli23/nestgo/middleware"
)
func main() {
cfg := core.DefaultConfig()
cfg.Addr = ":3000"
cfg.GlobalPrefix = "/api"
cfg.HealthCheck = true
db := NewDB() // implements OnModuleInit / OnModuleDestroy
users := NewUserService(db)
ctrl := NewUserController(users) // implements core.Controller
a := app.New(cfg, ginadapter.New)
a.Use(middleware.Recovery(), middleware.Logger(), middleware.RequestID())
a.Register(db, users, ctrl)
if err := a.Run(); err != nil {
log.Fatal(err)
}
}
You construct your objects in whatever order makes sense, hand them to Register, and Run.
Register: one method, classified by interface
Register accepts any values and looks at what each one implements:
| Implements | What happens |
|---|---|
core.Controller | its RegisterRoutes is called (with Prefix() and versioning applied) |
core.OnModuleInit | called before the server starts listening, in registration order |
core.OnModuleDestroy | called after the server drains, in reverse registration order |
A value can match several (a controller that also warms a cache in OnModuleInit). A value that matches none is a startup error — Start/Run return app: Register: value of type T implements none of ... — so a typo never silently drops a component.
Register in dependency order — db → services → controllers — so reverse destroy closes dependents first.
Lifecycle, exactly like the DI path
Both app and di delegate to the same internal bootstrap code, so the sequence is identical:
Start: middleware from Use → /health, /ready → global prefix, GlobalMiddlewares, RequestTimeout, global guards/pipes/interceptors/filters → controllers → OnModuleInit hooks → listen. Health probes are registered before the global guards/interceptors/filters, so they are never wrapped by them. If an init hook fails, the server never listens and Start returns the error.
Stop: drain in-flight requests (ShutdownTimeout) → Config.OnShutdown hooks → OnModuleDestroy hooks.
On the di path the same rule holds: services registered with di.Service are initialized before controllers and destroyed after them.
Run blocks until SIGINT/SIGTERM or a fatal server error (port in use, bad certificate), then stops gracefully and returns the error, if any. It never calls os.Exit — you decide.
Start, Wait, Stop for custom mains and tests
a := app.New(cfg, fiberadapter.New).Register(ctrl)
if err := a.Start(ctx); err != nil { return err } // listening in the background
// ... run other things, or in a test: send requests to cfg.Addr ...
err := a.Wait() // nil on signal/Stop, else the fatal error
_ = a.Stop(ctx) // idempotent; no-op before Start
a.Server() returns the adapter server for raw access (Underlying(), native routes).
Which should I use — app or di?
Choose app when | Choose di when |
|---|---|
| a small service, or a team new to Go | many modules with deep constructor graphs |
| you want compile-time wiring errors | you want NestJS-style modules and providers |
| you’d rather not learn a DI container | you swap implementations in tests via the graph |
They are interchangeable at the framework level: controllers, services, middleware, guards, and config work identically. You can start with app and move to di later without touching a handler.
Next steps
- Dependency Injection — the
dipath withdi.Module,di.Controller,di.Service - Controllers & Routing
- Production Guide — shutdown, TLS, health probes