package app import ( "fmt" "log/slog" ) // setupFn is a function that runs setup logic for a sub-system. type setupFn func() error // teardownFn is a function that runs teardown logic for a sub-system. type teardownFn func() // LifecycleOpts contains user-exposed options when defining a lifecycle. type LifecycleOpts struct { // DisableAutoload disables dependency autoloading (enabled by default). DisableAutoload bool } // Lifecycle represents the core application structure. Lifecycle manages // resources providing an orchestrator for setup and teardown of sub-systems. type Lifecycle struct { subsystems []*Subsystem setupTracker map[string]bool teardownTracker map[string]bool opts LifecycleOpts } // NewLifecycle creates a new Lifecycle instance with the given subsystems. func NewLifecycle(subsystems ...*Subsystem) *Lifecycle { // Ensure subsystems are unique unique := make(map[string]bool) for _, sub := range subsystems { if _, exists := unique[sub.name]; exists { panic(fmt.Sprintf("duplicate subsystem name: %s", sub.name)) } unique[sub.name] = true } return &Lifecycle{ subsystems: subsystems, setupTracker: make(map[string]bool), teardownTracker: make(map[string]bool), } } // WithOpts sets the options for the lifecycle. func (app *Lifecycle) WithOpts(opts LifecycleOpts) *Lifecycle { app.opts = opts return app } // Setup initializes all sub-systems in the order they were defined and checks // for dependencies. It returns an error if any sub-system fails to initialize or // if dependencies are not satisfied. Lifecycle.Teardown should always be run at the end // of the application lifecycle to ensure all resources are cleaned up properly. func (app *Lifecycle) Setup() error { setupCount := 0 for _, sub := range app.subsystems { if ok, err := app.setupSingle(sub); err != nil { return err } else if ok { setupCount++ } } slog.Info("Lifecycle sub-systems initialized, backend setup complete", "count", setupCount, "subsystems", mapToString(app.setupTracker)) return nil } // Teardown runs all sub-system teardown functions in reverse order of setup. // Teardown should always be run at the end of the application lifecycle // to ensure all resources are cleaned up properly. func (app *Lifecycle) Teardown() error { teardownCount := 0 for i := len(app.subsystems) - 1; i >= 0; i-- { if ok, err := app.singleTeardown(app.subsystems[i]); err != nil { return err } else if ok { teardownCount++ } } slog.Info("All sub-systems torn down, backend teardown complete", "count", teardownCount, "subsystems", mapToString(app.teardownTracker)) return nil } // Require adds sub-system(s) to the lifecycle and immediately runs any setup // function. Relevant when a sub-system is not part of the main application // but may still be conditionally necessary. Any sub-systems that are already // set up are ignored. func (app *Lifecycle) Require(subsystems ...*Subsystem) error { for _, sub := range subsystems { if sub == nil { return fmt.Errorf("sub-system is nil") } // Check if the sub-system is already set up if _, ok := app.setupTracker[sub.name]; ok { slog.Warn("sub-system already set up, ignoring", "subsystem", sub.name) continue } // Add the sub-system to the lifecycle app.subsystems = append(app.subsystems, sub) // Run the setup function for the sub-system if _, err := app.setupSingle(sub); err != nil { return fmt.Errorf("error setting up required subsystem '%s': %w", sub.name, err) } } slog.Info("New sub-systems initialized", "all", mapToString(app.setupTracker)) return nil } // setupSingle is a helper function to set up a single sub-system. func (app *Lifecycle) setupSingle(sub *Subsystem) (bool, error) { if sub == nil { return false, fmt.Errorf("sub-system is nil") } // Check if all dependencies are satisfied for _, dep := range sub.depends { if _, ok := app.setupTracker[dep.name]; !ok { if app.opts.DisableAutoload { return false, fmt.Errorf("dependency '%s' not satisfied for '%s'", dep.name, sub.name) } else { // Attempt to set up the dependency if _, err := app.setupSingle(dep); err != nil { return false, fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep.name, sub.name, err) } } } } if sub.setup != nil { // Run the setup function for the sub-system if err := sub.setup(); err != nil { return false, fmt.Errorf("error initializing '%s': %w", sub.name, err) } // Mark this subsystem as setup app.setupTracker[sub.name] = true return true, nil } return false, nil } // singleTeardown is a helper function to tear down a single sub-system. func (app *Lifecycle) singleTeardown(sub *Subsystem) (bool, error) { if sub == nil { return false, fmt.Errorf("sub-system is nil") } // Check if the sub-system was set up if _, ok := app.setupTracker[sub.name]; !ok { return false, nil } // Run the teardown function for the sub-system if sub.teardown != nil { sub.teardown() // Mark this subsystem as torn down app.teardownTracker[sub.name] = true return true, nil } return false, nil } // mapToString converts a map to an opinionated string representation. func mapToString(m map[string]bool) string { if len(m) == 0 { return "[]" } result := "[" for k, v := range m { if v { result += fmt.Sprintf("'%s' ", k) } } result = result[:len(result)-1] + "]" return result }