Dependency Injection in Go with NestGo

Dependency injection is optional — see Application Without DI if you’d rather wire plain Go constructors by hand. The di package gives you NestJS-style modules and providers on top of uber/fx, with a vocabulary that never requires typing fx. in your own code.

Modules and providers

A module groups a feature’s controller, services, and repositories under a name — the Go equivalent of a NestJS @Module():

var Module = di.Module("users",
    di.Controller(NewUserController),   // routes registered automatically
    di.Service(NewUserService),         // injectable by its concrete type
    di.Service(NewUserRepository),
)

func main() {
    di.NewApp(core.DefaultConfig(), ginadapter.New, users.Module, orders.Module).Run()
}

Constructor injection is just function parameters: NewUserService(repo *UserRepository) declares its dependency, and the container resolves it by type. No registration order matters inside a module — di.NewApp builds the graph from the constructor signatures, then runs app.Run().

The vocabulary

di wraps every fx primitive you need, so nothing in your code has to import go.uber.org/fx directly:

di function fx equivalent Purpose
di.Module(name, opts...) fx.Module groups related providers under a name
di.Controller(ctor) provide into the controllers group registers a controller’s constructor; its routes are wired automatically
di.Service(ctor) fx.Provide + automatic lifecycle hooks registers a service constructor, one instance, injectable by its concrete type
di.Supply(values...) fx.Supply provides already-constructed values (a *sql.DB, a config struct)
di.Invoke(fns...) fx.Invoke runs a function at startup with its parameters injected
di.Option fx.Option the option type every one of the above returns

Services with lifecycle

di.Service(ctor) takes a func returning (T) or (T, error) and creates exactly one instance of T, injectable anywhere else in the graph by its concrete type. If T implements core.OnModuleInit and/or core.OnModuleDestroy, the hooks are registered automatically — no separate annotation needed — and the service is constructed eagerly at startup so its OnModuleInit can run before the server listens:

type DB struct{ pool *pgxpool.Pool }

func NewDB() (*DB, error) {
    pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
    return &DB{pool: pool}, err
}

func (d *DB) OnModuleInit(ctx context.Context) error   { return d.pool.Ping(ctx) }
func (d *DB) OnModuleDestroy(ctx context.Context) error { d.pool.Close(); return nil }

var Module = di.Module("core", di.Service(NewDB))

di.AsInitHook / di.AsDestroyHook still exist for tagging a constructor’s result directly into the lifecycle groups, but each annotated call constructs a separate instance — prefer di.Service so your service and its lifecycle hooks share the one instance.

Controllers with lifecycle

A controller implementing OnModuleInit and/or OnModuleDestroy gets its hooks registered too. Controllers are scanned for these interfaces by the framework (CoreModule) after registration, so a controller implementing them is hooked just like a di.Service. It is initialized before the server starts listening on both the di path and the app path:

func (c *UserController) OnModuleInit(ctx context.Context) error {
    return c.svc.WarmCache(ctx)
}

Controllers that were previously registered with both di.AsController and di.AsInitHook/di.AsDestroyHook now have their hooks run on the controller instance as well — drop the extra annotations to avoid running the hook twice.

Lifecycle hooking for di.Service and controllers happens through di.CoreModule, which di.NewApp always includes.

Ordering

The sequence is identical to the app package, because both delegate to the same internal bootstrap code:

Start: middleware from di.Invoke/module setup → /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.

Stop: drain in-flight requests (ShutdownTimeout) → Config.OnShutdown hooks → OnModuleDestroy hooks.

One difference from app: app.Register destroys components in strict reverse registration order, but on the di path OnModuleDestroy hooks are collected into an fx group — within that group, fx does not guarantee order. Register dependencies as separate services with explicit constructor dependencies (rather than relying on destroy order) if teardown ordering matters to you.

Raw fx escape hatch

di.Option is a type alias for fx.Option, so raw fx options mix in anywhere a di.Option is expected — fx.Provide, fx.Annotate, fx.Decorate, or anything else from the fx API:

di.Module("users",
    di.Controller(NewUserController),
    fx.Provide(fx.Annotate(NewUserRepository, fx.As(new(Repository)))),
)

Reach for this when you need fx features di doesn’t wrap directly, such as annotated interfaces, named/grouped values beyond controllers and lifecycle hooks, or decorators.

Next steps

Source: github.com/ashrafAli23/nestgo