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