servicepack
July 27, 2026 · View on GitHub
___ ___ _____ _____ ___ ___ ___ _ ___ _ __
/ __| __| _ \ \ / /_ _/ __| __| _ \/_\ / __| |/ /
\__ \ _|| /\ V / | | (__| _|| _/ _ \ (__| ' <
|___/___|_|_\ \_/ |___\___|___|_|/_/ \_\___|_|\_\
servicepack
A Go service framework that runs your shit concurrently without fucking around.
Table of Contents
Getting Started
Core Concepts
Essential Tools
System Details
Framework Management
Internals
Reference
What is this?
You write services, this thing runs them. All your services go into one binary so you can debug the fuck out of service-to-service calls without dealing with distributed bullshit. Run everything locally, then deploy individual services as microservices when you're ready. Or just fuckin' deploy everything together, y not.
Quick Start (Make It Your Own in 30 Seconds)
# Clone this shit
git clone https://github.com/psyb0t/servicepack
cd servicepack
# Make it yours
make own MODNAME=github.com/yourname/yourproject
# Build and run
make build
./build/yourproject run
This will:
- Nuke the .git directory
- Replace the module name everywhere
- Set you up with a fresh go.mod
- Replace README with just your project name
- Run
git initto start fresh - Setup dependencies
- Create initial commit on main branch
You'll see the hello-world service spamming "Hello, World!" every 5 seconds. Hit Ctrl+C to stop it cleanly.
Just Want to Try It First?
git clone https://github.com/psyb0t/servicepack
cd servicepack
make run-dev
This builds a dev Docker image and runs it with debug logging. You'll see all the example services in action - retries, dependencies, allowed failures, one-shot jobs, and a crasher that takes everything down after ~30 seconds.
Creating Services
Create a new service:
make service NAME=my-cool-service
This shits out a skeleton service at internal/pkg/services/my-cool-service/. Edit the generated file, put your logic in the Run() method. Done - your service starts automatically.
Remove a service:
make service-remove NAME=my-cool-service
Service Interface
Every service implements this interface:
type Service interface {
Name() string // Return service name
Run(ctx context.Context) error // Your service logic goes here
Stop(ctx context.Context) error // Cleanup logic (optional)
}
The Run() method should:
- Listen for
ctx.Done()and return cleanly when cancelled - Return an error if something goes wrong (this will stop all services)
- Do whatever the fuck your service is supposed to do
The Stop() method is for cleanup - it runs when the app is shutting down.
Optional Interfaces
Services can opt into advanced behavior by implementing these:
// Retryable - service gets restarted on failure
type Retryable interface {
MaxRetries() int
RetryDelay() time.Duration // e.g. 5*time.Second
}
// AllowedFailure - service can die without killing everything
type AllowedFailure interface {
IsAllowedFailure() bool
}
// Dependent - service waits for other services to start first
type Dependent interface {
Dependencies() []string // service names
}
// ReadyNotifier - signal when actually ready to serve
type ReadyNotifier interface {
Ready() <-chan struct{}
}
// Commander - expose CLI subcommands
type Commander interface {
Commands() []*cobra.Command
}
Retry: When Run() returns an error, the service manager retries up to MaxRetries() times with RetryDelay() between attempts. If context is cancelled during the delay, it bails cleanly.
Allowed Failure: When a service fails (even after retries), its error gets logged but doesn't propagate - other services keep running. Perfect for non-critical shit like cache warmers or metrics exporters.
Dependencies: The service manager resolves a dependency graph using topological sort. Services with no deps start first, then their dependents, etc. Cyclic dependencies are detected and rejected. Dependencies on services not in the current process (external databases, services on other servers) are skipped with a debug log.
Ready Notification: Services that implement ReadyNotifier signal when they're actually ready (listening, connected, etc.). The service manager waits for the Ready channel before starting dependent services. Services without it are considered ready as soon as their goroutine launches.
CLI Commands: Services that implement Commander get their own CLI namespace: ./app <servicename> <subcommand>. Only that service gets instantiated - no other services are touched. Returns standard cobra commands so you get flags, args, help, everything for free.
You can combine them - a service can be retryable AND an allowed failure AND have dependencies AND signal readiness AND expose CLI commands.
Custom CLI Commands
cmd/commands.go is your hook to add CLI commands. It's never touched by framework updates:
// cmd/commands.go
package main
import "github.com/spf13/cobra"
func commands() []*cobra.Command {
return []*cobra.Command{
{
Use: "seed",
Short: "Seed the database",
Run: func(_ *cobra.Command, _ []string) {
// your logic
},
},
}
}
Then ./app seed just works. These are standalone commands separate from service commands.
Custom Initialization
cmd/init.go is your hook to run shit before the app starts. It's never touched by framework updates. Use it to add custom slog handlers, set up global config, or anything else:
// cmd/init.go
package main
import slogconfigurator "github.com/psyb0t/slog-configurator"
func init() {
slogconfigurator.AddHandler(myLokiHandler)
}
Every slog.Info/Error/etc call across the entire app - framework, services, everything - goes to all registered handlers. Want Loki? Datadog? Elasticsearch? Just write a slog.Handler and plug it in here.
Lifecycle Hooks
The App exposes hooks so you can run custom logic at specific points in the lifecycle without touching framework files:
// cmd/init.go
package main
import (
"context"
"github.com/yourname/yourproject/internal/app"
"github.com/yourname/yourproject/internal/pkg/metrics"
)
func init() {
// Runs before any service starts — use it to launch background
// goroutines, set up metrics pushers, warm caches, etc.
app.GetInstance().OnPreRun(func(ctx context.Context) {
go metrics.StartPush(ctx, "myapp")
})
// Runs after all services have stopped — use it for final
// cleanup, flushing buffers, closing connections, etc.
app.GetInstance().OnPostStop(func(ctx context.Context) {
metrics.Flush()
})
}
Hooks execute sequentially in registration order. Pre-run hooks receive the app context so spawned goroutines respect the app lifecycle. Multiple hooks can be registered — they all run.
How Services Actually Work
- Services are auto-discovered using the
gofindimpltool - The
scripts/make/service_registration.shscript finds all Service implementations - It generates
internal/pkg/services/services.gen.gowith aservices.Init()function services.Init()registers service factories (cheap, no connections) at startup- Factories are only called when actually needed:
./app runinstantiates all (filtered bySERVICES_ENABLED),./app <service> <subcommand>instantiates only that service
Service Filtering
By default, all services run. To run specific services:
export SERVICES_ENABLED="hello-world,my-cool-service"
./build/servicepack run
Leave SERVICES_ENABLED empty or unset to run all services.
The Makefile (Your New Best Friend)
Basic Commands
make all- Full pipeline: dep → lint-fix → test-coverage → buildmake build- Build the binary using Docker (static linking)make dep- Get dependencies withgo mod tidyandgo mod vendormake test- Run all tests with race detectionmake test-coverage- Run tests with 90% coverage requirement (excludes example services and cmd packages)make lint- Lint withgo fix(diff-only) + golangci-lint (80+ linters)make lint-fix- Applygo fixmodernizations + golangci-lint auto-fixesmake clean- Clean build artifacts and coverage files
Service Management
make service NAME=foo- Create new servicemake service-remove NAME=foo- Remove servicemake service-registration- Regenerate service discovery
Development
make run-dev- Run in development Docker containermake docker-build-dev- Build dev image
Docker
make docker-build- Build production Docker imagemake docker-build-dev- Build development Docker image
Framework Management
make servicepack-update- Update to latest servicepack framework (creates backup first)make servicepack-update-review- Review pending framework update changesmake servicepack-update-merge- Merge pending framework updatemake servicepack-update-revert- Revert pending framework updatemake own MODNAME=github.com/you/project- Make this framework your own
Backup Management
make backup- Create timestamped backup in/tmpand.backup/make backup-restore [BACKUP=filename.tar.gz]- Restore from backup (defaults to latest, nukes everything first)make backup-clear- Delete all backup files
Note: Framework updates (make servicepack-update) automatically create backups before making changes.
Script Customization
You can override any framework script by creating a user version:
# Create custom script (will override framework version)
cp scripts/make/servicepack/test.sh scripts/make/test.sh
# Edit your custom version
vim scripts/make/test.sh
The Makefile checks for user scripts first (scripts/make/), then falls back to framework scripts (scripts/make/servicepack/). This lets you customize any build step while preserving the ability to update the framework without conflicts.
Framework scripts (in scripts/make/servicepack/):
- Get updated when you run
make servicepack-update - Always preserved - your customizations won't get overwritten
User scripts (in scripts/make/):
- Take priority over framework scripts
- Never touched by framework updates
- Perfect for project-specific build customizations
Makefile Customization
The build system uses a split Makefile approach:
# Override any framework command by defining it in your Makefile
build: ## Custom build command
@echo "Running my custom build..."
@docker build -t myapp .
# Add your own custom commands
deploy: ## Deploy to production
@./deploy.sh
How it works:
Makefile.servicepack- Contains all framework commands (updated by framework)Makefile- Your file that includes servicepack + allows custom commands (never touched)- User commands override framework commands automatically
make helpshows both user and framework commands
Dockerfile Customization
Both development and production Docker environments use the override pattern:
# Customize development environment
cp Dockerfile.servicepack.dev Dockerfile.dev
vim Dockerfile.dev
# Customize production environment
cp Dockerfile.servicepack Dockerfile
vim Dockerfile
How it works:
Dockerfile.servicepack.dev- Framework development image (updated by framework)Dockerfile.dev- Your custom development image (never touched)Dockerfile.servicepack- Framework production image (updated by framework)Dockerfile- Your custom production image (never touched)make docker-build-devautomatically uses your custom development version if it exists
Architecture
cmd/main.go # Entry point, CLI setup
internal/app/ # Application layer
├── app.go # Main app orchestration
internal/pkg/
├── service-manager/ # Framework service orchestration
│ ├── service_manager.go # Concurrent service runner
│ ├── errors.go # Framework error definitions
│ └── *_test.go # Framework tests
└── services/ # User service space
├── services.gen.go # Auto-generated services.Init() function
├── hello-world/ # Example: basic long-running service
├── example-database/ # Example: retryable service
├── example-api/ # Example: service with dependencies
├── example-migrator/ # Example: one-shot with allowed failure
├── example-optional/ # Example: allowed failure
├── example-flaky/ # Example: fails then recovers
├── example-crasher/ # Example: crashes and kills everything
└── your-service/ # Your services go here
scripts/make/ # Build script system
├── servicepack/ # Framework scripts (updated by framework)
│ ├── build.sh # Docker build script
│ ├── dep.sh # Dependency management
│ ├── test.sh # Test runner
│ └── *.sh # Other framework scripts
└── [custom scripts] # User overrides (take priority)
Key Components
ServiceManager: Runs your services concurrently with dependency ordering, automatic retries, and allowed failures. Handles shutdown and error propagation. It's a singleton because globals are fine when you know what you're doing.
Service Registration: Auto-discovery using gofindimpl finds all your Service implementations and generates a services.Init() function that registers factories. No manual registration bullshit. Services are only instantiated when needed - run creates all, CLI commands create only the one they need.
App: Wrapper that runs the ServiceManager and handles the lifecycle shit. Exposes OnPreRun and OnPostStop hooks so downstream projects can inject custom logic without modifying framework files.
Environment Variables
The framework uses these:
# Logging (via slog-configurator)
LOG_LEVEL=debug # debug, info, warn, error
LOG_FORMAT=json # json, text
LOG_ADD_SOURCE=true # show file:line in logs
# Environment (via goenv)
ENV=dev # dev, prod (default: prod)
# Runner
RUNNER_SHUTDOWNTIMEOUT=10s # graceful shutdown timeout (default: 10s)
# Service filtering
SERVICES_ENABLED=service1,service2 # comma-separated, empty = all
# Your services can define their own env vars
Build System Details
The build system is dynamic as fuck:
- App name is extracted from
go.modautomatically - Binary gets built with static linking (no external deps)
- App name is injected at build time via ldflags
- Docker builds ensure consistent environment
Build Process
APP_NAME := $(shell head -n 1 go.mod | awk '{print \$2}' | awk -F'/' '{print $NF}')
build:
docker run --rm -v $(PWD):/app -w /app golang:1.26-alpine \
sh -c "apk add --no-cache gcc musl-dev && \
CGO_ENABLED=0 go build -a \
-ldflags '-extldflags \"-static\" -X main.appName=$(APP_NAME)' \
-o ./build/$(APP_NAME) ./cmd/..."
This means your binary name matches your module name automatically.
Framework Updates
Keep your servicepack framework up to date:
make servicepack-update
This runs a thin bootstrap (servicepack_update.sh) that:
- Checks for uncommitted changes (fails if found)
- Compares current version with latest
- Downloads the latest framework
- Hands off to
do_update.shfrom the freshly downloaded copy — so the newest update logic always drives the update, even when it's the update logic itself that changed (see note below)
do_update.sh then:
- Creates a backup
- Creates update branch
servicepack_update_to_VERSION - Syncs framework files
- Merges the framework's
go.moddependencies into yours upgrade-only — your own deps andCHANGELOG.mdare never overwritten or downgraded (see below) - Commits changes to update branch for review
- Leaves you on update branch to review and test
Why the split? The updater updates itself. If all the logic lived in the installed
servicepack_update.sh, a fix to that logic could never protect the update that installs it — you'd always run one-version-old update logic. By keepingservicepack_update.sha minimal bootstrap and running the real work from the freshly downloadeddo_update.sh, every future change to update behavior takes effect on the first update that ships it.
Review and Apply Updates
After running make servicepack-update:
# Review what changed
make servicepack-update-review
# Test the update
make dep && make service-registration && make test
# If satisfied, merge the update
make servicepack-update-merge
# If not satisfied, discard the update
make servicepack-update-revert
Customizing Updates with .servicepackupdateignore
Create a .servicepackupdateignore file to exclude files from framework updates:
# Custom framework modifications (these are already user files)
# Note: Dockerfile, Dockerfile.dev, Makefile, and scripts/make/ are automatically excluded
# Local configuration files
*.local
.env*
Framework vs User Files:
cmd/ # Framework files
internal/app/ # Framework files
internal/pkg/service-manager/ # Framework files
scripts/make/servicepack/ # Framework scripts (updated by servicepack-update)
scripts/make/ # User scripts (override framework, never touched)
Makefile.servicepack # Framework Makefile (updated by servicepack-update)
Makefile # User Makefile (includes servicepack, never touched)
Dockerfile.servicepack.dev # Framework development image (updated by servicepack-update)
Dockerfile.dev # User development image (overrides framework, never touched)
Dockerfile.servicepack # Framework production image (updated by servicepack-update)
Dockerfile # User production image (never touched)
.github/ # Framework files (CI/CD workflows)
.github/dependabot.yml # Starter config, ignored by default - yours to own
LICENSE # Your project license
.golangci.yml # Framework files
go.mod # Your deps preserved; framework deps merged upgrade-only
go.sum # Regenerated by `make dep` (never clobbered first)
CHANGELOG.md # Your project changelog - never touched
README.md # Your project docs
internal/pkg/services/ # Your services - never touched
Your dependencies are safe. go.mod / go.sum are never overwritten by
the update. Instead the framework's own require entries and tool directives
are merged into your go.mod upgrade-only: what's missing is added, what the
framework raised is bumped, and anything you already pin at an equal-or-higher
version is left alone. Framework dependency bumps land without ever downgrading
or dropping the deps your app added on top.
Use .servicepackupdateignore to exclude any framework files you've customized.
Pre-commit Hook
There's a pre-commit.sh script that runs make lint && make test-coverage. You can:
- Use your favorite pre-commit tool to manage hooks
- Use
ez-pre-committo auto-setup Git hooks that run this script - Just use the simple script as-is (it runs lint and coverage checks)
Testing
Tests are structured per component:
internal/app/app_test.go- Application tests with mock servicesinternal/pkg/service-manager/service_manager_test.go- Unit tests for retry, allowed failure, dependencies, concurrencyinternal/pkg/service-manager/service_manager_integration_test.go- Integration tests combining all features end-to-endinternal/pkg/service-manager/errors_test.go- Error definition and matching tests- Each service should have its own
*_test.gofiles
90% test coverage is required by default (excludes example services and cmd packages). The coverage check runs with race detection and fails if below threshold.
Test Isolation
ResetInstance()resets the singleton for clean test stateClearServices()clears all registered services- Mock services implement the Service interface for testing
- Tests should avoid calling
services.Init()and manually add mock services instead
Concurrency Model
- Each service runs in its own goroutine
- ServiceManager uses sync.WaitGroup for coordination
- Context cancellation for clean shutdown
- Services are started in dependency order (topological sort)
- Services in the same dependency group start concurrently
- Retryable services get restarted automatically on failure
- Allowed-failure services can die without killing everything
- Graceful shutdown cancels context and calls Stop() in reverse dependency order
- Per-service stop timeout (30s default) prevents hung shutdowns
- Panics in services are recovered and treated as errors
Error Handling
- Service errors bubble up through the ServiceManager
- First non-allowed-failure error stops all services
- Allowed failures are logged but don't propagate
- Retryable services exhaust retries before propagating
- Context errors (cancellation) are treated as clean shutdown
- All errors use
ctxerrorsfor context preservation ErrNoEnabledServices- no services registeredErrCyclicDependency- circular dependency detectedErrServicePanic- service panicked (recovered, treated as error)ErrStopTimeout- service didn't stop within timeout
Dependencies
Core dependencies:
log/slogwithslog-configurator- Logginggithub.com/spf13/cobra- CLIgithub.com/psyb0t/ctxerrors- Error handlinggithub.com/psyb0t/goenv- Environment detection (prod/dev)
Development dependencies:
golangci-lint- Comprehensive linting (80+ linters: errcheck, govet, staticcheck, gosec, etc.)testify- Testing assertions and mocksgofindimpl- Service auto-discovery tool
Directory Structure
.
├── cmd/
│ ├── main.go # Entry point
│ └── init.go # Your custom init hooks
├── pkg/runner/ # App lifecycle runner
├── internal/
│ ├── app/ # Application layer
│ └── pkg/services/ # Services
├── scripts/make/ # Build script system
│ ├── servicepack/ # Framework scripts (auto-updated)
│ └── [user scripts] # User overrides (take priority)
├── build/ # Build output
├── vendor/ # Vendored dependencies
├── Makefile # User Makefile (includes servicepack framework)
├── Makefile.servicepack # Framework Makefile (auto-updated)
├── Dockerfile # User production image (optional override)
├── Dockerfile.dev # User development image (optional override)
├── Dockerfile.servicepack # Framework production image (auto-updated)
├── Dockerfile.servicepack.dev # Framework development image (auto-updated)
└── servicepack.version # Framework version tracking
Example Services
The framework ships with example services that demonstrate every lifecycle pattern:
| Service | Pattern |
|---|---|
hello-world | Long-running, no deps, basic service |
example-database | Long-running, retryable (2 retries, 2s delay), signals ready after startup |
example-api | Long-running, depends on database + flaky |
example-migrator | One-shot, depends on database, allowed failure, CLI commands (up/down/status) |
example-optional | Allowed failure, fails immediately but app keeps running |
example-flaky | Retryable (2 retries, 1s delay), fails twice then recovers |
example-crasher | Retryable (2 retries, 3s delay), fails all retries and kills everything |
example-nested/http | Nested directory, shares package name server with grpc sibling |
example-nested/grpc | Nested directory, shares package name server with http sibling |
Services can live in nested directories under internal/pkg/services/. The codegen derives unique import aliases from the directory path, so example-nested/http and example-nested/grpc both use package server but get aliases examplenestedhttp and examplenestedgrpc - no collisions.
Run them all: go run ./cmd run or make run-dev
These get removed when you run make own.
Agent integrations
The skill works in any agent that reads .agents/skills/, and
installs natively in the clients below.
Claude Code
claude plugin marketplace add psyb0t/agents
claude plugin install servicepack@psyb0t
Codex
codex plugin marketplace add psyb0t/agents
codex plugin add servicepack@psyb0t
Installed via the marketplace, the skill invokes as $servicepack:servicepack. Codex also
picks the skill up automatically with no install in any repo containing .agents/skills/,
where it invokes as plain $servicepack.
OpenClaw
The skill is published to ClawHub on every release:
openclaw skills install @psyb0t/servicepack
Changelog
See CHANGELOG.md for release notes.
License
MIT