This guide explains how providers are created, cached, and cleaned up in modkit.
Providers in modkit follow a lazy singleton pattern:
- Lazy: Built on first
Get()call, not at bootstrap - Singleton: One instance per provider, cached for the app lifetime
stateDiagram-v2
direction LR
[*] --> Registered: Bootstrap
Registered --> Building: First Get()
Building --> Cached: Build success
Building --> Error: Build fails
Cached --> Cached: Subsequent Get()
Cached --> Cleanup: App shutdown
Cleanup --> [*]
note right of Registered
Factory function stored
Instance not created yet
end note
note right of Cached
Same instance returned
for all Get() calls
end note
note right of Cleanup
Call App.Close/CloseContext
(closes io.Closer providers)
end note
During kernel.Bootstrap(), providers are registered but not built:
app, err := kernel.Bootstrap(&AppModule{})
// At this point:
// - Module graph is validated
// - Provider factories are registered
// - No instances have been created yetWhat happens:
- Module graph is flattened and validated
- Visibility rules are computed
- Provider
Buildfunctions are stored in the container - Controllers are built (triggering provider builds as needed)
Providers are built when first accessed via Get():
// In a controller's Build function
svc, err := r.Get("users.service") // First call: builds the providerWhat happens:
- Check if the provider is already cached (cache miss on first call)
- Check if the provider is currently being built (cycle detection)
- Call the provider's
Buildfunction - Cache the result
- Return the instance
Build order is determined by access order, not registration order:
module.ModuleDef{
Providers: []module.ProviderDef{
{Token: "a", Build: buildA}, // Registered first
{Token: "b", Build: buildB}, // Registered second
{Token: "c", Build: buildC}, // Registered third
},
}
// If you call Get("c") first, the build order is: c, then b, then a
// (assuming c depends on b, and b depends on a)If a provider depends on itself (directly or indirectly), modkit returns a ProviderCycleError:
// Bad: A depends on B, B depends on A
{Token: "a", Build: func(r module.Resolver) (any, error) {
b, _ := r.Get("b") // A needs B
return &ServiceA{b: b}, nil
}},
{Token: "b", Build: func(r module.Resolver) (any, error) {
a, _ := r.Get("a") // B needs A → cycle!
return &ServiceB{a: a}, nil
}},Error:
ProviderCycleError: cycle detected while building "b": b → a → b
Once built, providers are cached as singletons:
// First call: builds the provider
svc, err := module.Get[UserService](r, "users.service")
// Second call: returns cached instance
svc2, err := module.Get[UserService](r, "users.service")
if err != nil {
return nil, err
}
// svc and svc2 are the same instance
fmt.Println(svc == svc2) // trueWhy singletons?
- Simple and predictable
- No hidden state or scope management
- Easy to reason about
- Works well for most backend services
modkit provides explicit shutdown via App.Close() / App.CloseContext(ctx). Providers that implement
io.Closer are closed in reverse build order when you call these methods.
For a summary of how this compares to NestJS lifecycle hooks, see the NestJS Compatibility Guide.
func main() {
app, err := kernel.Bootstrap(&AppModule{})
if err != nil {
log.Fatal(err)
}
router := mkhttp.NewRouter()
mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers)
if err := mkhttp.Serve(":8080", router); err != nil {
log.Printf("server error: %v", err)
}
if err := app.CloseContext(context.Background()); err != nil {
log.Printf("shutdown error: %v", err)
}
}Close() closes providers in reverse build order.
Close is idempotent and safe to call: once it completes (even if it returned aggregated errors),
subsequent calls return nil and do not re-close providers.
For context-aware shutdown, use CloseContext(ctx):
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := app.CloseContext(ctx); err != nil {
// Returns ctx.Err() if canceled or timed out
log.Printf("shutdown error: %v", err)
}CloseContext checks ctx.Err() before closing and before each closer. If the context
is canceled or times out, it returns ctx.Err() and leaves the app eligible for a
later Close() retry. While a close is in progress, concurrent close calls are no-ops.
Use App.Close() if you don't need context-aware cancellation behavior.
func main() {
app, err := kernel.Bootstrap(&AppModule{})
if err != nil {
log.Fatal(err)
}
// Get resources that need cleanup
db, err := module.Get[*sql.DB](app, "db.connection")
if err != nil {
log.Fatal(err)
}
// Defer cleanup
defer db.Close()
// Start server
router := mkhttp.NewRouter()
mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers)
mkhttp.Serve(":8080", router) // Blocks until shutdown
// Cleanup runs when server exits
}Create a provider that tracks resources needing cleanup:
type Cleanup struct {
funcs []func() error
}
func (c *Cleanup) Register(fn func() error) {
c.funcs = append(c.funcs, fn)
}
func (c *Cleanup) Run() error {
var errs []error
for _, fn := range c.funcs {
if err := fn(); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %v", errs)
}
return nil
}
// In module
{Token: "cleanup", Build: func(r module.Resolver) (any, error) {
return &Cleanup{}, nil
}},
{Token: "db", Build: func(r module.Resolver) (any, error) {
cleanup, err := module.Get[*Cleanup](r, "cleanup")
if err != nil {
return nil, err
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
cleanup.Register(db.Close)
return db, nil
}},
// In main
func main() {
app, err := kernel.Bootstrap(&AppModule{})
if err != nil {
log.Fatal(err)
}
cleanup, err := module.Get[*Cleanup](app, "cleanup")
if err != nil {
log.Fatal(err)
}
defer cleanup.Run()
// ...
}Use context cancellation for coordinated shutdown:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Pass context to providers that need it
app, _ := kernel.Bootstrap(&AppModule{ctx: ctx})
// Set up signal handling
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
cancel() // Signal shutdown to providers using ctx
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := app.CloseContext(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
}()
// Start server
mkhttp.Serve(":8080", router) // Graceful shutdown on SIGINT/SIGTERM
}Use signal.NotifyContext for standard Go signal handling, then shut down the server and close providers:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
app, err := kernel.Bootstrap(&AppModule{})
if err != nil {
log.Fatal(err)
}
router := mkhttp.NewRouter()
mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers)
srv := &http.Server{
Addr: ":8080",
Handler: router,
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("server error: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("server shutdown error: %v", err)
}
if err := app.Close(); err != nil {
log.Printf("app close error: %v", err)
}
}Providers are singletons and cannot be request-scoped. For request-specific data, use context.Context:
type contextKey string
const UserKey contextKey = "user"
const RequestIDKey contextKey = "request_id"
// Store in middleware
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := authenticateUser(r)
ctx := context.WithValue(r.Context(), UserKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Retrieve in handler
func (c *Controller) Get(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(UserKey).(*User)
// ...
}See Context Helpers Guide for typed context patterns.
| Aspect | Fx | modkit |
|---|---|---|
| Lifecycle hooks | OnStart/OnStop |
App.Close() / CloseContext |
| Scopes | Singleton, request, custom | Singleton only |
| Automatic cleanup | Yes | Explicit close |
| Aspect | NestJS | modkit |
|---|---|---|
| Scopes | Singleton, Request, Transient | Singleton only |
onModuleInit |
Provider hook | Not supported |
onModuleDestroy |
Provider hook | App.Close() / CloseContext |
| Request-scoped | Framework-managed | Use context.Context |
-
Keep providers stateless where possible
- Prefer functional transforms over mutable state
- Stateful providers (e.g., DB pools) should be thread-safe
-
Initialize expensive resources lazily
- Don't open connections in module constructors
- Let providers build when first needed
-
Track resources that need cleanup
- Implement
io.Closerand callapp.Close()/CloseContext - Or use manual patterns for custom cleanup
- Implement
-
Use context for request-scoped data
- Don't try to make request-scoped providers
- Pass
context.Contextthrough function calls
-
Test lifecycle explicitly
- Bootstrap in tests to verify no cycles
- Test cleanup logic with real resources
type DatabaseModule struct {
dsn string
}
func (m *DatabaseModule) Definition() module.ModuleDef {
return module.ModuleDef{
Name: "database",
Providers: []module.ProviderDef{{
Token: "db.connection",
Build: func(r module.Resolver) (any, error) {
// Lazy: connection opened on first access
db, err := sql.Open("mysql", m.dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
// Verify connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping db: %w", err)
}
// Singleton: same connection pool for all consumers
return db, nil
},
}},
Exports: []module.Token{"db.connection"},
}
}
func main() {
app, err := kernel.Bootstrap(&AppModule{})
if err != nil {
log.Fatal(err)
}
// Get DB for cleanup
db, err := module.Get[*sql.DB](app, "db.connection")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Start server (DB connection created on first query)
router := mkhttp.NewRouter()
mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers)
mkhttp.Serve(":8080", router)
}- Modules Guide — Module composition and visibility
- Providers Guide — Dependency injection patterns
- Context Helpers — Typed context keys for request-scoped data
- Testing Guide — Testing lifecycle and cleanup