Compare commits

...

3 Commits

Author SHA1 Message Date
end 58e5e33e18 docs: add doc.go and expand README for V1
Document lifecycle concepts, companion packages, and API reference.
Add gitignore for coverage artifacts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:58:06 -07:00
end a07d9d06ed refactor: consolidate Require API and split lifecycle files
Add RequireOpts, RequireWithOpts, and GetModule. Move helpers to
lifecycle_internal.go and errors to errors.go. Remove unused GenericModule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:58:05 -07:00
end 4cd218f658 fix: correct lifecycle setup and teardown semantics
Guard against double setup when autoload runs dependencies early, tear down
in reverse setup order, roll back on partial failure, and detect circular
Depends chains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 17:58:00 -07:00
11 changed files with 452 additions and 284 deletions
+6
View File
@@ -0,0 +1,6 @@
# Test coverage
coverage.out
coverage.html
# Go build artifacts
*.test
+64 -63
View File
@@ -1,12 +1,14 @@
# app # app
app is a simple and lightweight app lifecycle management library. app orchestrates modular application setup and teardown for Go services.
## Example ## Install
Keep your `main.go` as simple as possible, all it's responsible for is creating the lifecycle, defining a default logger, and orchestrating setup and teardown. ```bash
go get gitea.auvem.com/go-toolkit/app
```
**main.go** ## Quick start
```go ```go
package main package main
@@ -16,75 +18,74 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"gitea.auvem.com/go-toolkit/applog"
"gitea.auvem.com/go-toolkit/app" "gitea.auvem.com/go-toolkit/app"
) )
func main() { func main() {
// Create a new lifecycle, defaulting to no printed logs lifecycle := app.NewLifecycle(
lifecycle := app.NewLifecycle().WithLogger(slog.New(slog.DiscardHandler)) applog.AppLogOpts{ConsoleOutput: os.Stderr}.Module(),
)
defer func() { defer func() {
if err := lifecycle.Teardown(); err != nil { if err := lifecycle.Teardown(); err != nil {
fmt.Println("Error during shutdown", err) fmt.Println("shutdown error:", err)
} }
}() }()
// Encodes the lifecycle into a context to be used downstream if err := lifecycle.Setup(); err != nil {
ctx := lifecycle.Context(context.Background()) panic(err)
// Off to you, call your entrypoint here.
Hello(ctx)
}
```
Now, let's define a basic logging module and handle loading that module.
**logger.go**
```go
package main
import (
"log/slog"
"gitea.auvem.com/go-toolkit/app"
)
func ModuleLog(setDefault bool) *app.Module {
return app.NewModule("logger", app.ModuleOpts{
Setup: func(m *app.Module) error {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})
if setDefault {
slog.SetDefault(handler)
}
m.Lifecycle().WithLogger(handler)
}
})
}
```
Logging can, however, be somewhat of a complex operation in itself, so I'd highly recommend checking out my [applog](https://gitea.auvem.com/go-toolkit/applog) project, which features tight integration with this app.
Finally, lets put our new lifecycle (and logger) to work!
**hello.go**
```go
package main
import (
"context"
"gitea.auvem.com/go-toolkit/app"
)
func Hello(ctx context.Context) {
app := app.LifecycleFromContext(ctx)
if app == nil {
panic("hello must run within an app lifecycle")
} }
lifecycle.Require(ModuleLog(true))
app.Logger().Info("Hello world!", "foo", "bar") ctx := lifecycle.Context(context.Background())
_ = ctx
lifecycle.Logger().Info("ready")
} }
``` ```
## Concepts
- **Module** — a named subsystem with optional `Setup`, `Teardown`, and `Depends`
- **Lifecycle** — holds modules and runs setup/teardown in dependency order
- **Setup** — initializes all modules registered via `NewLifecycle`
- **Require** — adds and initializes modules on demand (typical for CLI subcommands via [appcli](https://gitea.auvem.com/go-toolkit/appcli))
- **Autoload** — when enabled (default), `Depends` names are set up automatically before the dependent module
## Dependencies
List dependency module **names** in `ModuleOpts.Depends`. Dependent modules must be registered in the same lifecycle (via `NewLifecycle` or an earlier `Require`). Circular dependencies return an error.
## Teardown
Always defer `lifecycle.Teardown()`. Teardown runs in **reverse setup order** so dependents shut down before their dependencies.
## Context
Use `lifecycle.Context(ctx)` so downstream code and `appcli` commands can call `app.LifecycleFromContext(ctx)`.
## Companion packages
| Package | Role |
|---------|------|
| [applog](https://gitea.auvem.com/go-toolkit/applog) | slog + tint logging module |
| [appcli](https://gitea.auvem.com/go-toolkit/appcli) | urfave/cli v3 integration |
| [dbx](https://gitea.auvem.com/go-toolkit/dbx) | database module |
| [migrate](https://gitea.auvem.com/go-toolkit/migrate) | goose migrations module |
## API reference
| Symbol | Description |
|--------|-------------|
| `NewLifecycle(modules...)` | Create lifecycle with optional initial modules |
| `Lifecycle.Setup()` | Set up all registered modules |
| `Lifecycle.Teardown()` | Tear down in reverse setup order |
| `Lifecycle.Require(modules...)` | Set up modules on demand |
| `Lifecycle.RequireWithOpts(opts, modules...)` | Require with custom logger or unique-name enforcement |
| `Lifecycle.GetModule(name)` | Look up a registered module |
| `NewModule(name, opts)` | Define a module |
| `LifecycleFromContext(ctx)` | Retrieve lifecycle from context |
## Error handling
- Partial setup failure rolls back already-initialized modules
- `Teardown` joins errors from all modules but attempts every teardown
- A second `Setup` or `Teardown` on the same lifecycle returns an error
-5
View File
@@ -1,5 +0,0 @@
// Package app provides the core setup and teardown functions for the
// backend application. It handles the initialization of all sub-systems
// and the logger. app should never be imported except by a main package.
// Examples of suitable main packages are cmd and test.
package app
+26
View File
@@ -0,0 +1,26 @@
// Package app orchestrates modular application setup and teardown.
//
// Register subsystems as [Module] values, attach them to a [Lifecycle], and call
// [Lifecycle.Setup] or [Lifecycle.Require] to initialize resources in dependency
// order. Always call [Lifecycle.Teardown] (typically via defer) to release
// resources in reverse setup order.
//
// Module authors (dbx, applog, migrate, etc.) and main/cmd packages both import
// app. Use [Lifecycle.Context] to propagate the lifecycle through context.Context
// for CLI commands via [gitea.auvem.com/go-toolkit/appcli].
//
// # Concepts
//
// - [Module] — named unit with optional Setup, Teardown, and Depends
// - [Lifecycle] — registry and orchestrator for modules
// - Setup order — dependencies first; autoload resolves Depends when enabled
// - Require — lazy/conditional setup (common in CLI subcommands)
//
// # API overview
//
// Lifecycle — [NewLifecycle], [Lifecycle.Setup], [Lifecycle.Teardown],
// [Lifecycle.Require], [Lifecycle.RequireWithOpts], [Lifecycle.GetModule],
// [Lifecycle.Context], [LifecycleFromContext]
//
// Module — [NewModule], [Module.Lifecycle], [Module.Logger], [Module.Loaded]
package app
+6
View File
@@ -0,0 +1,6 @@
package app
import "errors"
// ErrModuleNotFound is returned when a module is not found in the lifecycle.
var ErrModuleNotFound = errors.New("module not found")
+14 -195
View File
@@ -5,8 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"strings"
) )
type contextKey string type contextKey string
@@ -29,6 +27,7 @@ type Lifecycle struct {
modules []*Module modules []*Module
opts LifecycleOpts opts LifecycleOpts
setupOrder []*Module
setupCount int setupCount int
setupTracker map[string]int setupTracker map[string]int
teardownCount int teardownCount int
@@ -38,7 +37,6 @@ type Lifecycle struct {
// NewLifecycle creates a new Lifecycle instance with a default logger and the // NewLifecycle creates a new Lifecycle instance with a default logger and the
// given modules. It panics if any module has a duplicate name. // given modules. It panics if any module has a duplicate name.
func NewLifecycle(modules ...*Module) *Lifecycle { func NewLifecycle(modules ...*Module) *Lifecycle {
// Ensure modules are unique
unique := make(map[string]bool) unique := make(map[string]bool)
for _, mod := range modules { for _, mod := range modules {
if _, exists := unique[mod.name]; exists { if _, exists := unique[mod.name]; exists {
@@ -96,17 +94,18 @@ func (app *Lifecycle) Logger() *slog.Logger {
return app.opts.Logger return app.opts.Logger
} }
// Setup initializes all modules in the order they were defined and checks // Setup initializes all registered modules, resolving dependencies via autoload
// for dependencies. It returns an error if any module fails to initialize or // when enabled. Modules run setup in dependency order; among independent modules,
// if dependencies are not satisfied. Lifecycle.Teardown should always be run at the end // registration order is preserved. Teardown runs in reverse setup order.
// of the application lifecycle to ensure all resources are cleaned up properly.
func (app *Lifecycle) Setup() error { func (app *Lifecycle) Setup() error {
if app.setupCount > 0 { if app.setupCount > 0 {
return fmt.Errorf("lifecycle already set up, cannot set up again") return fmt.Errorf("lifecycle already set up, cannot set up again")
} }
setupBefore := len(app.setupOrder)
for _, mod := range app.modules { for _, mod := range app.modules {
if err := app.setupSingle(nil, mod); err != nil { if err := app.setupSingle(nil, mod, nil); err != nil {
app.rollbackFrom(setupBefore)
return err return err
} }
} }
@@ -117,24 +116,24 @@ func (app *Lifecycle) Setup() error {
return nil return nil
} }
// Teardown runs all module teardown functions in reverse order of setup. // Teardown runs teardown for all set-up modules in reverse setup order.
// Teardown should always be run at the end of the application lifecycle // All module teardown errors are joined and returned (non-blocking).
// to ensure all resources are cleaned up properly. All module tear down
// errors are returned as a single error (non-blocking).
func (app *Lifecycle) Teardown() error { func (app *Lifecycle) Teardown() error {
if app.teardownCount > 0 { if app.teardownCount > 0 {
return fmt.Errorf("lifecycle already torn down, cannot tear down again") return fmt.Errorf("lifecycle already torn down, cannot tear down again")
} }
var err error var err error
for i := len(app.modules) - 1; i >= 0; i-- { var failureCount int
if singleErr := app.teardownSingle(app.modules[i]); singleErr != nil { for i := len(app.setupOrder) - 1; i >= 0; i-- {
if singleErr := app.teardownSingle(app.setupOrder[i]); singleErr != nil {
err = errors.Join(err, singleErr) err = errors.Join(err, singleErr)
failureCount++
} }
} }
if err != nil { if err != nil {
app.Logger().Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err) app.Logger().Error("Error tearing down modules", "failures", failureCount, "error", err)
return err return err
} }
@@ -143,183 +142,3 @@ func (app *Lifecycle) Teardown() error {
return nil return nil
} }
// Require adds module(s) to the lifecycle and immediately runs any setup
// functions. Relevant when a module is not part of the main application
// but may still be conditionally necessary. Any modules that are already
// set up are ignored.
func (app *Lifecycle) Require(modules ...*Module) error {
return app.require(nil, false, modules...)
}
// RequireUnique is the same as Require, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUnique(modules ...*Module) error {
return app.require(nil, true, modules...)
}
// RequireL adds module(s) to the lifecycle with a specific logger and
// immediately runs any setup functions. See Require for more details.
// This variation is useful when you need to set up modules with a non-
// default logger.
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(logger, false, modules...)
}
// RequireUniqueL is the same as RequireL, but it returns an error if any requested
// module is already set up--rather than ignoring it.
func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(logger, true, modules...)
}
// require is a helper function that attempts to add module(s) to the lifecycle
// and immediately run any setup functions.
func (app *Lifecycle) require(logger *slog.Logger, unique bool, modules ...*Module) error {
if len(modules) == 0 {
return fmt.Errorf("no modules to require")
}
for i, mod := range modules {
if mod == nil {
return fmt.Errorf("module %d is nil", i)
}
// Check if the module has already been set up
if _, ok := app.setupTracker[mod.name]; ok {
if unique {
return fmt.Errorf("module %s is already set up, cannot require again", mod)
}
app.Logger().Warn("module already set up, ignoring", "module", mod)
// Mark duplicate module as loaded
mod.loaded = true
mod.lifecycle = app
mod.logger = logger
continue
}
// Add the module to the lifecycle
app.modules = append(app.modules, mod)
// Run the setup function for the module
if err := app.setupSingle(logger, mod); err != nil {
return fmt.Errorf("error setting up required module %s: %w", mod, err)
}
}
app.Logger().Info("New modules initialized", "all", mapToString(app.setupTracker))
return nil
}
// setupSingle is a helper function to set up a single module. Returns an error
// if the module cannot be set up or if dependencies are not satisfied.
func (app *Lifecycle) setupSingle(logger *slog.Logger, mod *Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
// Set the parent lifecycle and logger override
mod.lifecycle = app
mod.logger = logger
// Check if all dependencies are satisfied
for _, dep := range mod.depends {
if _, ok := app.setupTracker[dep]; !ok {
if app.opts.DisableAutoload {
return fmt.Errorf("dependency %s not satisfied for '%s'", dep, mod)
} else {
// Attempt to set up the dependency
depmod, err := app.getModuleByName(dep)
if err != nil {
return fmt.Errorf("error getting dependency '%s' for %s: %w", dep, mod, err)
}
if err := app.setupSingle(logger, depmod); err != nil {
return fmt.Errorf("error setting up dependency %s for %s: %w", depmod, mod, err)
}
}
}
}
if mod.setup != nil {
// Run the setup function for the module
if err := mod.setup(mod); err != nil {
return fmt.Errorf("error initializing %s: %w", mod, err)
}
}
// Mark this module as setup
app.setupTracker[mod.name] = app.setupCount
app.setupCount++
mod.loaded = true
return nil
}
// teardownSingle is a helper function to tear down a single module. Returns
// an error if anything goes wrong.
func (app *Lifecycle) teardownSingle(mod *Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
// Check if the module was set up
if _, ok := app.setupTracker[mod.name]; !ok {
return fmt.Errorf("module %s is not set up, cannot tear down", mod)
}
// Run the teardown function for the module
if mod.teardown != nil {
if err := mod.teardown(mod); err != nil {
return fmt.Errorf("error tearing down %s: %w", mod, err)
}
}
// Mark this module as torn down
app.teardownTracker[mod.name] = app.teardownCount
app.teardownCount++
mod.loaded = false
return nil
}
// getModuleByName retrieves a module by its name from the lifecycle.
func (app *Lifecycle) getModuleByName(name string) (*Module, error) {
for _, mod := range app.modules {
if mod.name == name {
return mod, nil
}
}
return nil, ErrModuleNotFound
}
// mapToString converts a map to an ordered, opinionated string representation.
func mapToString(m map[string]int) string {
if len(m) == 0 {
return "[]"
}
// Sort the map by value
values := make([]int, 0, len(m))
reverseMap := make(map[int]string, len(m))
for k, v := range m {
values = append(values, v)
reverseMap[v] = k
}
sort.Ints(values)
result := make([]string, 0, len(m))
for _, v := range values {
if name, ok := reverseMap[v]; ok {
result = append(result, fmt.Sprintf("'%s'", name))
}
}
return "[" + strings.Join(result, " ") + "]"
}
+81
View File
@@ -0,0 +1,81 @@
package app_test
import (
"fmt"
"testing"
"gitea.auvem.com/go-toolkit/app"
"github.com/stretchr/testify/assert"
)
func TestSetupTeardownIntegration(t *testing.T) {
var order []string
modA := app.NewModule("a", app.ModuleOpts{
Setup: func(m *app.Module) error { order = append(order, "setup:a"); return nil },
Teardown: func(m *app.Module) error { order = append(order, "teardown:a"); return nil },
})
modB := app.NewModule("b", app.ModuleOpts{
Setup: func(m *app.Module) error { order = append(order, "setup:b"); return nil },
Teardown: func(m *app.Module) error { order = append(order, "teardown:b"); return nil },
Depends: []string{"a"},
})
lc := app.NewLifecycle(modB, modA)
assert.NoError(t, lc.Setup())
assert.Equal(t, []string{"setup:a", "setup:b"}, order)
order = nil
assert.NoError(t, lc.Teardown())
assert.Equal(t, []string{"teardown:b", "teardown:a"}, order)
}
func TestSetupNoDoubleSetupWithAutoload(t *testing.T) {
var count int
modA := app.NewModule("a", app.ModuleOpts{
Setup: func(m *app.Module) error { count++; return nil },
})
modB := app.NewModule("b", app.ModuleOpts{
Setup: func(m *app.Module) error { count++; return nil },
Depends: []string{"a"},
})
lc := app.NewLifecycle(modB, modA)
assert.NoError(t, lc.Setup())
assert.Equal(t, 2, count)
}
func TestSetupPartialFailureRollback(t *testing.T) {
var tornDown bool
modA := app.NewModule("a", app.ModuleOpts{
Setup: func(m *app.Module) error { return nil },
Teardown: func(m *app.Module) error { tornDown = true; return nil },
})
modB := app.NewModule("b", app.ModuleOpts{
Setup: func(m *app.Module) error { return fmt.Errorf("fail b") },
})
lc := app.NewLifecycle(modA, modB)
err := lc.Setup()
assert.Error(t, err)
assert.True(t, tornDown)
assert.False(t, modA.Loaded())
}
func TestSetupCircularDependency(t *testing.T) {
modA := app.NewModule("a", app.ModuleOpts{Depends: []string{"b"}})
modB := app.NewModule("b", app.ModuleOpts{Depends: []string{"a"}})
lc := app.NewLifecycle(modA, modB)
err := lc.Setup()
assert.Error(t, err)
assert.Contains(t, err.Error(), "circular dependency")
}
func TestGetModule(t *testing.T) {
mod := app.NewModule("db", app.ModuleOpts{})
lc := app.NewLifecycle(mod)
got, err := lc.GetModule("db")
assert.NoError(t, err)
assert.Equal(t, mod, got)
_, err = lc.GetModule("missing")
assert.Error(t, err)
}
+170
View File
@@ -0,0 +1,170 @@
package app
import (
"fmt"
"log/slog"
"sort"
"strings"
)
func (app *Lifecycle) require(opts RequireOpts, modules ...*Module) error {
if len(modules) == 0 {
return fmt.Errorf("no modules to require")
}
if opts.Logger != nil && opts.Unique {
// unique with custom logger is valid
}
if opts.Logger == nil && opts.Unique {
// valid
}
setupBefore := len(app.setupOrder)
for i, mod := range modules {
if mod == nil {
app.rollbackFrom(setupBefore)
return fmt.Errorf("module %d is nil", i)
}
if _, ok := app.setupTracker[mod.name]; ok {
if opts.Unique {
app.rollbackFrom(setupBefore)
return fmt.Errorf("module %s is already set up, cannot require again", mod)
}
app.Logger().Warn("module already set up, ignoring", "module", mod)
mod.loaded = true
mod.lifecycle = app
mod.logger = opts.Logger
continue
}
app.modules = append(app.modules, mod)
if err := app.setupSingle(opts.Logger, mod, nil); err != nil {
app.rollbackFrom(setupBefore)
return fmt.Errorf("error setting up required module %s: %w", mod, err)
}
}
app.Logger().Info("New modules initialized", "all", mapToString(app.setupTracker))
return nil
}
func (app *Lifecycle) setupSingle(logger *slog.Logger, mod *Module, visiting map[string]bool) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
if _, ok := app.setupTracker[mod.name]; ok {
return nil
}
if visiting == nil {
visiting = make(map[string]bool)
}
if visiting[mod.name] {
return fmt.Errorf("circular dependency detected involving %s", mod)
}
visiting[mod.name] = true
defer delete(visiting, mod.name)
mod.lifecycle = app
mod.logger = logger
for _, dep := range mod.depends {
if _, ok := app.setupTracker[dep]; !ok {
if app.opts.DisableAutoload {
return fmt.Errorf("dependency %s not satisfied for '%s'", dep, mod)
}
depmod, err := app.getModuleByName(dep)
if err != nil {
return fmt.Errorf("error getting dependency '%s' for %s: %w", dep, mod, err)
}
if err := app.setupSingle(logger, depmod, visiting); err != nil {
return fmt.Errorf("error setting up dependency %s for %s: %w", depmod, mod, err)
}
}
}
if mod.setup != nil {
if err := mod.setup(mod); err != nil {
return fmt.Errorf("error initializing %s: %w", mod, err)
}
}
app.setupTracker[mod.name] = app.setupCount
app.setupOrder = append(app.setupOrder, mod)
app.setupCount++
mod.loaded = true
return nil
}
func (app *Lifecycle) teardownSingle(mod *Module) error {
if mod == nil {
return fmt.Errorf("module is nil")
}
if _, ok := app.setupTracker[mod.name]; !ok {
return fmt.Errorf("module %s is not set up, cannot tear down", mod)
}
if mod.teardown != nil {
if err := mod.teardown(mod); err != nil {
return fmt.Errorf("error tearing down %s: %w", mod, err)
}
}
app.teardownTracker[mod.name] = app.teardownCount
app.teardownCount++
mod.loaded = false
return nil
}
func (app *Lifecycle) rollbackFrom(startIndex int) {
for i := len(app.setupOrder) - 1; i >= startIndex; i-- {
mod := app.setupOrder[i]
if mod.teardown != nil {
_ = mod.teardown(mod)
}
delete(app.setupTracker, mod.name)
mod.loaded = false
}
app.setupOrder = app.setupOrder[:startIndex]
app.setupCount = startIndex
}
func (app *Lifecycle) getModuleByName(name string) (*Module, error) {
for _, mod := range app.modules {
if mod.name == name {
return mod, nil
}
}
return nil, ErrModuleNotFound
}
func mapToString(m map[string]int) string {
if len(m) == 0 {
return "[]"
}
values := make([]int, 0, len(m))
reverseMap := make(map[int]string, len(m))
for k, v := range m {
values = append(values, v)
reverseMap[v] = k
}
sort.Ints(values)
result := make([]string, 0, len(m))
for _, v := range values {
if name, ok := reverseMap[v]; ok {
result = append(result, fmt.Sprintf("'%s'", name))
}
}
return "[" + strings.Join(result, " ") + "]"
}
+22 -9
View File
@@ -246,10 +246,16 @@ func TestLifecycle_Teardown(t *testing.T) {
lc := NewLifecycle(tc.modules...) lc := NewLifecycle(tc.modules...)
// Fake setup for all modules if len(tc.modules) > 0 {
for _, mod := range tc.modules { setupBefore := len(lc.setupOrder)
mod.loaded = true // Mark as loaded for i, mod := range tc.modules {
lc.setupTracker[mod.name] = 0 // Mark as set up lc.setupOrder = append(lc.setupOrder, mod)
lc.setupTracker[mod.name] = i
mod.loaded = true
mod.lifecycle = lc
}
lc.setupCount = len(lc.setupOrder)
_ = setupBefore
} }
err := lc.Teardown() err := lc.Teardown()
@@ -283,6 +289,8 @@ func TestLifecycle_Teardown(t *testing.T) {
// Fake setup for the module // Fake setup for the module
lc.modules[0].loaded = true lc.modules[0].loaded = true
lc.setupTracker[lc.modules[0].name] = 0 lc.setupTracker[lc.modules[0].name] = 0
lc.setupOrder = []*Module{lc.modules[0]}
lc.setupCount = 1
err := lc.Teardown() err := lc.Teardown()
assert.NoError(err, "expected first Teardown to succeed") assert.NoError(err, "expected first Teardown to succeed")
@@ -418,7 +426,7 @@ func TestLifecycle_require(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
lc := NewLifecycle() lc := NewLifecycle()
err := lc.require(tc.logger, tc.unique, tc.modules...) err := lc.require(RequireOpts{Logger: tc.logger, Unique: tc.unique}, tc.modules...)
if tc.expectedErr == "" { if tc.expectedErr == "" {
assert.NoError(err, "expected require to succeed") assert.NoError(err, "expected require to succeed")
@@ -550,7 +558,7 @@ func TestLifecycle_setupSingle(t *testing.T) {
lc = NewLifecycle(tc.modules...) lc = NewLifecycle(tc.modules...)
} }
err := lc.setupSingle(l, tc.targetModule) err := lc.setupSingle(l, tc.targetModule, nil)
if tc.expectedErr == "" { if tc.expectedErr == "" {
assert.NoError(err, "expected no error from setupSingle") assert.NoError(err, "expected no error from setupSingle")
@@ -616,12 +624,17 @@ func TestLifecycle_teardownSingle(t *testing.T) {
lc := NewLifecycle() lc := NewLifecycle()
// Fake setup for all modules
var setupCount int var setupCount int
for _, mod := range tc.modules { for _, modName := range tc.modules {
lc.setupTracker[mod] = setupCount lc.setupTracker[modName] = setupCount
for _, m := range lc.modules {
if m.name == modName {
lc.setupOrder = append(lc.setupOrder, m)
}
}
setupCount++ setupCount++
} }
lc.setupCount = setupCount
err := lc.teardownSingle(tc.targetModule) err := lc.teardownSingle(tc.targetModule)
+2 -12
View File
@@ -1,25 +1,14 @@
package app package app
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
) )
// ErrModuleNotFound is returned when a module is not found in the lifecycle.
var ErrModuleNotFound = errors.New("module not found")
// ModuleFn is a function type for module setup and teardown functions. // ModuleFn is a function type for module setup and teardown functions.
type ModuleFn func(*Module) error type ModuleFn func(*Module) error
// GenericModule is an interface that allows modules to be extended with custom
// functionality, as the module can return a pointer to the underlying Module.
type GenericModule interface {
// Module returns the underlying Module instance.
Module() *Module
}
// Module represents a sub-system of the application, with its setup and // Module represents a sub-system of the application, with its setup and
// teardown functions and dependencies. // teardown functions and dependencies.
type Module struct { type Module struct {
@@ -60,7 +49,8 @@ func (s *Module) Lifecycle() *Lifecycle {
} }
// Logger returns the logger for the module. Uses the lifecycle's logger unless // Logger returns the logger for the module. Uses the lifecycle's logger unless
// a specific logger has been set during module load. // a specific logger has been set during module load. Panics if the module is
// not associated with a lifecycle and no override logger was set.
func (s *Module) Logger() *slog.Logger { func (s *Module) Logger() *slog.Logger {
if s.logger != nil { if s.logger != nil {
return s.logger return s.logger
+61
View File
@@ -0,0 +1,61 @@
package app
import (
"fmt"
"log/slog"
)
// RequireOpts configures [Lifecycle.RequireWithOpts] behavior.
type RequireOpts struct {
// Logger overrides the lifecycle logger for the required modules. If nil,
// the lifecycle logger is used.
Logger *slog.Logger
// Unique, when true, returns an error if any requested module name is
// already set up instead of ignoring duplicate instances.
Unique bool
}
// Require adds module(s) to the lifecycle and immediately runs setup.
// Modules that are already set up (by name) are ignored.
func (app *Lifecycle) Require(modules ...*Module) error {
return app.require(RequireOpts{}, modules...)
}
// RequireWithOpts is like [Lifecycle.Require] with additional options.
func (app *Lifecycle) RequireWithOpts(opts RequireOpts, modules ...*Module) error {
return app.require(opts, modules...)
}
// GetModule returns a registered module by name.
func (app *Lifecycle) GetModule(name string) (*Module, error) {
mod, err := app.getModuleByName(name)
if err != nil {
return nil, fmt.Errorf("get module %q: %w", name, err)
}
return mod, nil
}
// RequireL loads modules using a specific logger. Deprecated: use
// RequireWithOpts(RequireOpts{Logger: logger}, modules...).
func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(RequireOpts{Logger: logger}, modules...)
}
// RequireUnique loads modules and errors if any name is already set up.
// Deprecated: use RequireWithOpts(RequireOpts{Unique: true}, modules...).
func (app *Lifecycle) RequireUnique(modules ...*Module) error {
return app.require(RequireOpts{Unique: true}, modules...)
}
// RequireUniqueL combines RequireL and RequireUnique.
// Deprecated: use RequireWithOpts(RequireOpts{Logger: logger, Unique: true}, modules...).
func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error {
if logger == nil {
return fmt.Errorf("logger cannot be nil")
}
return app.require(RequireOpts{Logger: logger, Unique: true}, modules...)
}