A Go base app structure with lifecycle management, configuration loading, and event hooks built on top of Uber FX.
It gives you a small, opinionated skeleton for long-running services:
- Lifecycle:
Boot → Start → wait → Stop(orRestart), driven byRun. - Hooks:
OnBoot,OnStartandOnStopmiddleware chains built on gowool/hook, so you can run code before and after each phase. - Configuration: layered loading from raw bytes, files, environment variables and
.env, followed by defaults and validation. - Signals:
SIGINT/SIGTERMstop the app gracefully,SIGUSR1restarts it in place (same PID) viaexec. - Dependency injection: everything you register ends up in an
fx.App; theapp.Appinstance itself is available to your constructors.
go get github.com/rumorsflow/appRequires Go 1.27+.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"github.com/rumorsflow/app"
)
// Config is loaded from config.json and then overridden by MYAPP_* env vars.
type Config struct {
Addr string `json:"addr" env:"ADDR"`
}
func (c *Config) SetDefaults() {
if c.Addr == "" {
c.Addr = ":8080"
}
}
func (c *Config) Validate() error {
if c.Addr == "" {
return errors.New("addr is required")
}
return nil
}
func newServer(lc fx.Lifecycle, cfg Config, a app.App) *http.Server {
srv := &http.Server{Addr: cfg.Addr}
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s\n", a.Name(), a.Version())
})
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
return err
}
go srv.Serve(ln)
return nil
},
OnStop: srv.Shutdown,
})
return srv
}
func main() {
a := app.NewBaseApp(app.Config{
Name: "myapp",
Version: "1.0.0",
StartTimeout: 15 * time.Second,
StopTimeout: 15 * time.Second,
ConfigFiles: []string{"config.json"},
EnvPrefix: "MYAPP_",
ConfigUnmarshal: func(_ context.Context, data []byte, out any) error {
return json.Unmarshal(data, out)
},
})
// Use a real fx logger instead of the default fxevent.NopLogger.
a.OnBoot().BindFunc(func(e *app.BootEvent) error {
e.Logger = &fxevent.ConsoleLogger{W: os.Stderr}
return e.Next()
})
// Load Config and supply it to the fx container.
a.OnBoot().BindFunc(app.LoadConfig[Config]())
// Register providers and invokes.
a.OnBoot().BindFunc(app.Options(
fx.Provide(newServer),
fx.Invoke(func(*http.Server) {}),
))
if err := a.Run(context.Background()); err != nil {
log.Fatal(err)
}
}Run it, then:
curl localhost:8080 # -> myapp 1.0.0
kill -USR1 $(pidof myapp) # graceful restart, same PID
kill -TERM $(pidof myapp) # graceful stopNewBaseApp takes an app.Config:
| Field | Type | Description |
|---|---|---|
Name |
string |
Application name, exposed via App.Name(). |
Version |
string |
Application version, exposed via App.Version(). |
StartTimeout |
time.Duration |
Deadline for Start (hooks + fx OnStart). Must be > 0, otherwise the start context is already expired. |
StopTimeout |
time.Duration |
Deadline for Stop/Restart (hooks + fx OnStop). Must be > 0. |
ConfigUnmarshal |
func(ctx context.Context, data []byte, out any) error |
Decoder used for ConfigRaw and ConfigFiles (JSON, YAML, TOML, ...). If nil, raw bytes and files are skipped entirely. |
ConfigRaw |
[]byte |
Inline config document, decoded first. |
ConfigFiles |
[]string |
Config file paths, decoded in order after ConfigRaw. A missing file is an error. |
EnvPrefix |
string |
Prefix for environment variables (e.g. MYAPP_). Overrides EnvOptions.Prefix when non-empty. |
EnvOptions |
*env.Options |
Extra options for caarlos0/env (required-if-no-default, tag name, custom parsers, ...). |
App.LoadConfig(ctx, &cfg1, &cfg2, ...) fills each target in this order, so later sources override earlier ones:
ConfigRaw(ifConfigUnmarshalis set and the raw document is non-empty)- each entry of
ConfigFiles, in order (ifConfigUnmarshalis set) - environment variables via
env.ParseWithOptionsusing theenv:"..."struct tags SetDefaults()if the target implements it- validation, if the target implements one of the interfaces below
Because defaults are applied after all sources, SetDefaults should only fill zero values, as in the quick start above.
type defaulter interface{ SetDefaults() }
// Exactly one of these is called, in this order of preference:
type validatableWithContext interface{ ValidateWithContext(context.Context) error }
type validatableCtx interface{ Validate(context.Context) error }
type validatable interface{ Validate() error }A validation error is returned wrapped as failed to validate config: <err>.
On package init, godotenv.Load() is called, so a .env file in the working directory is loaded into the process environment. If DOTENV_PATH is set, that file is loaded as well. Existing environment variables are never overwritten by either file.
app.LoadConfig[C]() returns an OnBoot handler that loads a C through App.LoadConfig and supplies it to the fx container via fx.Supply. Your constructors can then simply depend on C:
a.OnBoot().BindFunc(app.LoadConfig[Config]())
a.OnBoot().BindFunc(app.Options(fx.Provide(func(cfg Config) *Thing { ... })))You can call it several times with different types if your configuration is split across structs.
Run(ctx)
├─ Boot(ctx) OnBoot chain → builds the fx.App
├─ Start(ctx) OnStart chain → fxApp.Start (bounded by StartTimeout)
├─ wait for: SIGINT/SIGTERM | SIGUSR1 | direct Stop()/Restart()
└─ Stop(ctx) OnStop chain → fxApp.Stop (bounded by StopTimeout)
Restart(ctx) OnStop chain → fxApp.Stop → syscall.Exec(self)
BoottriggersOnBootand, at the end of the chain, creates thefx.Appfrom the collectedBootEvent.Options. The app registers itself in the container asapp.App, so any constructor may take anapp.Appparameter.BootEvent.Loggerstarts asfxevent.NopLogger; replace it in a handler to get fx logs. Setting it tonilmakesBootfail.StarttriggersOnStartand starts the fx app inside the chain.StoptriggersOnStopand stops the fx app. It is idempotent and safe to call concurrently: the first call performs the shutdown, every later call blocks until it finishes and returns the same result. A stopped app cannot be started again.Restartbehaves likeStopwithStopEvent.IsRestart == true, then replaces the current process with a fresh instance of the same binary (/proc/self/exe, falling back toos.Executable()) using the same arguments and environment. On success it never returns. fx stop errors are ignored so a failed graceful shutdown does not block the exec. Not supported on Windows.RunchainsBootandStart, then blocks until a signal arrives orStop/Restartis called from application code, and returns the shutdown result.
| Signal | Behaviour |
|---|---|
SIGINT, SIGTERM |
Graceful Stop, Run returns its error. |
SIGUSR1 |
Graceful Restart (Linux/macOS only). |
To trigger a restart from inside the application, either send the signal yourself or call the package-level helper, which sends SIGUSR1 to the own process:
if err := app.Restart(); err != nil { ... }If you prefer calling BaseApp.Restart(ctx) directly from code that is itself managed by the app (an HTTP handler, a worker), detach it so the graceful shutdown does not wait for the caller:
go func() { _ = a.Restart(context.WithoutCancel(ctx)) }()Restart drops the caller's cancellation on purpose: a dying request must not cut the shutdown short.
OnBoot, OnStart and OnStop return *hook.Hook[*Event] values. Handlers form a middleware chain: each handler receives the event and must call event.Next() to continue. Code before Next() runs before the following handlers (and before the built-in step at the end of the chain); code after Next() runs once they have completed.
a.OnStart().BindFunc(func(e *app.StartEvent) error {
log.Println("starting...") // before fx OnStart hooks
if err := e.Next(); err != nil {
return err
}
log.Println("started") // after fx OnStart hooks
return nil
})Returning an error without calling Next() short-circuits the chain; the error propagates to Boot/Start/Stop/Restart.
Handlers run in registration order. Use Bind with a hook.Handler to set an explicit ID (to replace or later Unbind a handler) or a Priority (lower runs first).
| Event | Fields | Notes |
|---|---|---|
BootEvent |
App, Ctx, Logger, Options []fx.Option |
Append to Options to register fx providers/invokes; set Logger to an fxevent.Logger. |
StartEvent |
App, Ctx |
Ctx carries the StartTimeout deadline. |
StopEvent |
App, Ctx, IsRestart |
Ctx carries the StopTimeout deadline. IsRestart is true when the shutdown was triggered by Restart. |
app.Options(opts ...fx.Option)returns anOnBoothandler that appends the given fx options.app.LoadConfig[C]()returns anOnBoothandler that loads and supplies a config struct (see above).
type App interface {
Name() string
Version() string
LoadConfig(ctx context.Context, outs ...any) error
OnBoot() *hook.Hook[*BootEvent]
Boot(ctx context.Context) error
StartTimeout() time.Duration
OnStart() *hook.Hook[*StartEvent]
Start(ctx context.Context) error
StopTimeout() time.Duration
OnStop() *hook.Hook[*StopEvent]
Stop(ctx context.Context) error
Restart(ctx context.Context) error
Run(ctx context.Context) error
}*BaseApp implements it and is supplied to the fx container as App, so you can depend on it from any constructor:
fx.Provide(func(a app.App) *Health {
return &Health{name: a.Name(), version: a.Version()}
})go test ./...Restart tests short-circuit the OnStop chain before the built-in exec step; otherwise the test binary would be replaced.
This project is licensed under the MIT License - see the LICENSE file for details.