feat(launcher): initial stable release v0.9.0

Application lifecycle manager enforcing a three-phase init/wire/start sequence with reverse-order graceful shutdown and per-component stop timeouts.

What's included:
- `Component` interface (OnInit / OnStart / OnStop) and `Hook` type for BeforeStart wiring functions
- `Launcher` interface with Append, BeforeStart, Run (blocks on SIGINT/SIGTERM), and idempotent Shutdown(ctx)
- `New(logger, opts...)` constructor with configurable ComponentStopTimeout (default 15 s); no global state

Tested-via: todo-api POC integration
Reviewed-against: docs/adr/
This commit is contained in:
2026-03-18 23:49:12 +00:00
commit f2e3faa1d6
15 changed files with 1109 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
# ADR-001: Three-Phase Lifecycle
**Status:** Accepted
**Date:** 2026-03-18
## Context
Application bootstrap has distinct concerns that must happen in a specific order:
resource allocation (open database connections, bind ports), dependency wiring
(connect components to each other after all are ready), and service activation
(start accepting requests, launch background goroutines). Conflating these phases
leads to ordering bugs: a component trying to use a dependency that has not been
initialised yet, or a service starting to accept requests before the database
connection pool is ready.
## Decision
The `Launcher` orchestrates four sequential phases, implemented in `Run()`:
1. **OnInit** — called for all registered components in registration order. Opens
connections, allocates resources. No component is started yet.
2. **BeforeStart** — all `Hook` functions registered via `BeforeStart(hooks ...Hook)`
are called in registration order, after all `OnInit` calls have succeeded. This
is the dependency injection wiring phase: all components are initialised and can
be queried, but none are serving yet.
3. **OnStart** — called for all components in registration order. Starts goroutines,
begins serving. If any `OnStart` fails, `stopAll` is called immediately and `Run`
returns an error.
4. **Wait**`Run` blocks on either an OS signal (`SIGINT`, `SIGTERM`) or a
programmatic `Shutdown()` call.
5. **OnStop (shutdown)**`stopAll` is called, running `OnStop` for all components
in reverse registration order.
Each phase gate is explicit: if any step returns an error, the lifecycle halts and
the error is returned to the caller. The caller (typically `main`) decides whether
to call `os.Exit(1)`.
## Consequences
- `OnInit` can safely assume nothing is running yet — safe to block on slow
operations like initial DNS resolution or schema migration.
- `BeforeStart` hooks see a fully initialised set of components, making it the
correct place for wiring that requires cross-component knowledge.
- `OnStart` can safely assume all dependencies are wired and all peers are
initialised — it is safe to start serving or spawning goroutines.
- The three-phase split eliminates an entire class of "component not ready" race
conditions without requiring any synchronisation between components.
- The `Component` interface requires all three methods to be implemented. Components
with no meaningful action for a phase return `nil`.

View File

@@ -0,0 +1,48 @@
# ADR-002: Reverse-Order Shutdown
**Status:** Accepted
**Date:** 2026-03-18
## Context
When stopping an application, components must be stopped in the reverse of the order
they were started. A component that depends on another (e.g. an HTTP server that
uses a database connection pool) must be stopped before the component it depends on.
If the database pool were closed first, any in-flight request handled by the HTTP
server would fail with a connection error rather than completing gracefully.
## Decision
`stopAll` iterates `l.components` from the last index to the first:
```go
for i := len(l.components) - 1; i >= 0; i-- {
// stop l.components[i]
}
```
Components are typically registered in dependency order (dependencies first, then
dependents). Reverse-order shutdown therefore stops dependents before dependencies.
Each component's `OnStop` runs in its own goroutine. A `time.After` ticker enforces
the per-component `ComponentStopTimeout` (default 15 seconds). If a component does
not return within the timeout, the launcher logs an error and moves on to the next
component — it does not block the remainder of the shutdown sequence.
`Shutdown(ctx context.Context) error` provides a caller-side wait: it closes
`shutdownCh` (idempotent via `sync.Once`) and then blocks until `doneCh` is closed
(when `Run` returns) or until `ctx` is done. The caller's context controls how long
the caller is willing to wait for the entire shutdown; `ComponentStopTimeout`
controls how long each individual component gets.
## Consequences
- Shutdown order mirrors startup order in reverse, which is correct for any DAG of
component dependencies without requiring an explicit dependency graph.
- Each component's timeout is independent — a single slow component does not block
others from stopping.
- `Shutdown` is idempotent: calling it multiple times from multiple goroutines (e.g.
an OS signal handler and a health-check endpoint) is safe.
- If `ComponentStopTimeout` is exceeded, the component is abandoned — resources may
not be fully released. This is the correct trade-off for a graceful shutdown with
a deadline; the alternative (waiting indefinitely) is worse.

View File

@@ -0,0 +1,43 @@
# ADR-003: BeforeStart Hooks for Dependency Injection Wiring
**Status:** Accepted
**Date:** 2026-03-18
## Context
After all components have been initialised (`OnInit`), some wiring cannot be
expressed at construction time because it requires the initialised state of multiple
components simultaneously. For example, an HTTP server may need to register routes
that reference handler functions which themselves hold references to a database
client — but the database client only becomes ready after `OnInit` runs, which is
after all components are constructed.
One alternative is to wire everything in `main` before calling `Run`, but that
requires `main` to know the internal structure of every component, defeating
encapsulation. Another alternative is to wire in `OnStart`, but at that point other
components may already be running and the window for setup errors is narrower.
## Decision
`Launcher.BeforeStart(hooks ...Hook)` registers functions of type `func() error`
that are called after all `OnInit` calls succeed and before any `OnStart` call
begins. Hooks are called in registration order. If any hook returns an error, `Run`
returns that error immediately without proceeding to `OnStart`.
`Hook` is a plain function type with no parameters beyond what closures capture.
This allows hooks to close over the components they wire, without `launcher` needing
to know anything about those components beyond the `Component` interface.
## Consequences
- Dependency injection wiring is expressed as closures registered with
`BeforeStart`, keeping `main` as the composition root without exposing internals.
- All `OnInit` guarantees (connections open, ports bound, resources allocated) are
satisfied before any hook runs — hooks can safely call methods on initialised
components.
- Hook errors abort the lifecycle cleanly. No components have started yet, so no
cleanup of running services is needed (though `OnStop` will still run for any
components that successfully ran `OnInit`, consistent with ADR-001's behaviour
when `OnStart` fails).
- The pattern scales to any number of wiring steps without adding methods to the
`Component` interface.