Services and lifecycle
Servicepack supervises services that live in the same process. It provides startup ordering, readiness gates, failure policy, service-scoped logs, and one coordinated shutdown path. It does not turn in-process calls into a network protocol or solve distributed-systems concerns for services you split out later.
For the implementation-level rules and test setup, see the service-manager deep dive and runner deep dive.
The required contract
Every service implements:
type Service interface {
Name() string
Run(ctx context.Context) error
Stop(ctx context.Context) error
}
Name must be the stable name used by SERVICES_ENABLED and dependency declarations. Run must return when its context is cancelled. Stop is where you close listeners, drain work, flush a client, or do other bounded cleanup.
Do not use Stop as the first mechanism that tells a service to stop. The manager cancels its run context before calling Stop; Run should be written to observe that cancellation on its own.
Use ctxscope at service boundaries so all logs include the service name:
func (s *Worker) Run(ctx context.Context) error {
ctx = ctxscope.Set(ctx, ctxscope.Attr("service", ServiceName))
logger := ctxscope.GetLogger(ctx)
logger.Info("worker started")
for {
select {
case <-ctx.Done():
return nil
case item := <-s.items:
if err := s.handle(ctx, item); err != nil {
return ctxerrors.Wrap(err, "handle item")
}
}
}
}
The application entry point also sets process-wide binary and, for normal builds, commit attributes. Do not replace those with ad-hoc global logging.
Optional contracts
Implement any combination of these interfaces on the same service.
| Interface | Effect |
|---|---|
Retryable | Retry a failed Run up to MaxRetries() times, waiting RetryDelay() between attempts. 0 means no retry. |
AllowedFailure | After retries are exhausted, log the failure and leave the rest of the application running. |
Dependent | Start after named services in this binary. Cycles fail startup. |
ReadyNotifier | Block later dependency groups until the service closes Ready(). |
Commander | Add ./build/<app> <service> <subcommand> commands, instantiating only that service. |
Ordering is not readiness
Dependent only controls launch order. A dependency that does not implement ReadyNotifier is considered ready as soon as its goroutine has been launched, not once it has opened a socket or established a database connection.
If api cannot operate before database is usable, database should expose a channel and close it only after connecting, while api declares the name as a dependency:
func (s *Database) Ready() <-chan struct{} { return s.readyCh }
func (s *API) Dependencies() []string { return []string{"database"} }
Services in the same dependency group start concurrently. The manager waits for every ReadyNotifier in that group before starting the next group.
Dependency names that are not registered in the current process are logged and ignored. That makes it possible to use the same business design in a composed local binary and in a separately deployed setup, but it also means an external database, queue, or microservice still needs its own connection/retry/readiness handling.
Failure and retry policy
The first non-allowed terminal failure ends the application. Later concurrent failures are logged but do not block shutdown. A panic in Run is converted to a service error and follows that same policy.
AllowedFailure is not a retry setting: a service can be both Retryable and AllowedFailure, in which case it retries first and becomes non-fatal only after the retry budget is exhausted. Use this only for work whose disappearance does not make the process lie about its health.
Selectively run services
SERVICES_ENABLED filters service factories before instantiation:
SERVICES_ENABLED=api,price-worker ./build/my-service run
An empty or unset value runs every registered service. A selected service does not automatically pull in its dependencies; include the services needed for that local run. If filtering yields no services, startup returns an error.
Commands and lifecycle hooks
Commander is for commands owned by one service. It lets an operator invoke, for example, my-service database migrate without building the rest of the application’s service graph.
cmd/commands.go is for app-level Cobra commands that do not belong to a specific service. cmd/init.go is the extension point for application hooks:
func init() {
app.GetInstance().OnPreRun(func(ctx context.Context) {
// start bounded application-level work here
})
app.GetInstance().OnPostStop(func(ctx context.Context) {
// flush/close after services have stopped
})
}
Hooks run sequentially in registration order. Keep them short and make any goroutine they start respect the supplied context.
Shutdown path
pkg/runner listens for SIGINT and SIGTERM, as well as the supplied parent context and application errors. It then creates a shutdown context bounded by RUNNER_SHUTDOWNTIMEOUT (default 10s) and calls application stop.
The service manager cancels every running service, then stops dependency groups in reverse order; services within each group stop concurrently. The manager’s own per-service stop default is 30s, but the runner’s parent deadline is normally shorter, so the whole process is usually capped by the runner’s 10 seconds. Set RUNNER_SHUTDOWNTIMEOUT high enough for legitimate cleanup, but do not make shutdown unbounded.
Local composition versus microservices
Keep services together when shared release cadence, shared local debugging, and in-process coordination are the simplest honest design. Split them when they need independent scaling, ownership, security boundaries, or failure isolation. The split point means replacing in-process contracts with explicit network or message contracts, not pretending a Dependent declaration will order another deployment.
Read architecture for the process-level picture and development for testing this in Docker.