Service Runner: Simple Safe Modular

A tiny Go library for composing service modules with a clean lifecycle.

go get github.com/go-srvc/srvc@latest

Why srvc?

Real services run multiple long-lived components (HTTP, DB, signals, telemetry). Wiring up their startup order, graceful shutdown, panic recovery, and error reporting by hand is repetitive and easy to get wrong.

One small interface

Every module implements Init, Run, Stop, and ID. The runner sequences and supervises them so your main stays a list of components. Plain functions slot in through InitMod, RunMod, StopMod, CtxMod, and IdleMod. Optional toggles any module with a flag.

Safe shutdown

Panics are recovered. Errors from Init, Run, and Stop are aggregated. Stop runs in reverse order on every initialised module, even when something failed mid-flight.

Zero deps in core

The srvc package is standard library only: the runner, the Module interface, and thin function adapters. Pull in just the modules you need from go-srvc/mods: HTTP, SQL, OpenTelemetry, signals, tickers.

No zombie services

When any Run returns, the whole service shuts down. Pair with sigmod for clean SIGINT and SIGTERM handling, and let RunAndExit drop the exit code.

Lifecycle, visualized

Four modules going through srvc's lifecycle under different scenarios. One row plays at a time; hover any row to lock the animation to it.

Clean shutdown

Signal triggers shutdown. Modules stop in reverse order.

Init error

M3's Init fails. M1 and M2, the already-initialised modules, get Stop in reverse. M4 never starts.

Panic recovery

M3 panics during Run. The panic is recovered as an error and the rest shut down gracefully.

A practical example

A full HTTP service with OpenTelemetry logs, traces, and metrics, a Postgres pool, and graceful shutdown on SIGINT or SIGTERM. Modules compose, the runner supervises, your main stays declarative.

package main

import (
	"fmt"
	"net/http"
	"os"

	"github.com/XSAM/otelsql"
	"github.com/go-srvc/mods/httpmod"
	"github.com/go-srvc/mods/logmod"
	"github.com/go-srvc/mods/metermod"
	"github.com/go-srvc/mods/sigmod"
	"github.com/go-srvc/mods/sqlxmod"
	"github.com/go-srvc/mods/tracemod"
	"github.com/go-srvc/srvc"
	semconv "go.opentelemetry.io/otel/semconv/v1.30.0"

	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	db := sqlxmod.New(
		sqlxmod.WithOtel("pgx", os.Getenv("DSN"),
			otelsql.WithAttributes(semconv.DBSystemNamePostgreSQL),
		),
	)
	srvc.RunAndExit(
		logmod.New(),
		tracemod.New(),
		srvc.Optional(os.Getenv("METRICS") == "1", metermod.New()),
		sigmod.New(),
		db,
		httpmod.New(
			httpmod.WithAddr(":8080"),
			httpmod.WithHandler(handler(db)),
		),
	)
}

func handler(db *sqlxmod.DB) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var version string
		if err := db.DB().GetContext(r.Context(), &version, "SELECT version()"); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		fmt.Fprintf(w, "db version: %s\n", version)
	})
}

Functions as modules

Not everything deserves a struct. srvc ships five thin adapters that turn plain functions into modules. They are glue only: no options, no defaults, no logic of their own. Anything that wraps a real dependency belongs in a proper module, yours or one from go-srvc/mods.

package main

import (
	"context"

	"github.com/go-srvc/srvc"
)

func main() {
	srvc.RunAndExit(
		// Called during init, Run blocks until shutdown.
		srvc.InitMod("migrations", runMigrations),
		// Init and stop pair without any work of its own during run.
		srvc.IdleMod("cache", openCache, closeCache),
		// Called during run with a context that is canceled on shutdown.
		srvc.CtxMod("consumer", consume),
		// Called during run, service shuts down once it returns.
		srvc.RunMod("job", doWork),
		// Called during stop, in reverse order like any other module.
		srvc.StopMod("flush", flushBuffers),
	)
}

func runMigrations() error { return nil }
func openCache() error     { return nil }
func closeCache() error    { return nil }
func doWork() error        { return nil }
func flushBuffers() error  { return nil }

func consume(ctx context.Context) error {
	<-ctx.Done()
	return nil
}

RunMod and CtxMod run their function during the run phase and end the service when it returns; CtxMod cancels the context on Stop. InitMod, StopMod, and IdleMod have no run-phase work, so their Run blocks until shutdown. Stop order stays reverse of the module list.

Optional modules

Optional toggles a module with a flag. A disabled module is dropped before the init phase, so none of its Init, Run, or Stop functions are called, and its ID is logged as disabled. The rest of the module list runs as if it was never there.

package main

import (
	"net/http"
	"os"

	"github.com/go-srvc/mods/httpmod"
	"github.com/go-srvc/mods/metermod"
	"github.com/go-srvc/mods/sigmod"
	"github.com/go-srvc/srvc"
)

func main() {
	srvc.RunAndExit(
		sigmod.New(),
		// Dropped before init when METRICS is unset, logged as disabled.
		srvc.Optional(os.Getenv("METRICS") == "1", metermod.New()),
		srvc.Optional(os.Getenv("DEBUG") == "1", debugServer()),
		httpmod.New(httpmod.WithAddr(":8080")),
	)
}

func debugServer() srvc.Module {
	return httpmod.New(
		httpmod.WithAddr("localhost:6060"),
		httpmod.WithHandler(http.DefaultServeMux),
	)
}

The module is still constructed by the caller, so keep heavy work in Init rather than the constructor. A disabled module may be nil. Enabled modules keep their position, so init and stop order stay predictable regardless of which flags are set.

srvc

Package overview, exported API, and runnable examples extracted from the latest tagged release.

v1.13.0

Mods catalog

Ready-made modules for HTTP, SQL, OpenTelemetry, signals, and tickers. Pick a tagged release on each mod's page.

8 modules