Prompt file imported from theplant/speckit-starter-go (
.windsurf/workflows/theplant.lifecycle-setup.md). Fill in{{arguments}}before use. Copyright stays with the author.
User Input
{{arguments}}
You MUST consider the user input before proceeding (if not empty).
Goal
Help project setup standardized service startup architecture with:
- Configuration management with
confx(embedded defaults + file + env + flags) - Lifecycle management with
lifecycle(dependency injection, graceful shutdown) - Provider pattern (each component as independent provider function)
This workflow focuses on framework and patterns, not specific business logic or directory structures.
Core Principles
- Configuration First: All runtime configuration through
confx, never scatteredos.Getenv()calls - Provider Independence: Each component is a provider function with explicit dependencies
- Lifecycle Managed: All long-running components managed by
lifecycle - Flexible Structure: Directory structure adapts to project, not the other way around
- Minimal Dependency: Only introduce dependencies that are actually used by other providers in the lifecycle
Execution Steps
Step 1: Analyze Current Project
-
Examine existing structure
- Identify entry point(s) (
main.go,cmd/*/main.go) - Find existing configuration patterns (env vars, config files, hardcoded values)
- List components that should become providers (DB, services, servers, etc.)
- Check for existing
confx/lifecycleusage
- Identify entry point(s) (
-
Propose directory structure
Based on analysis, propose a structure. Common patterns:
Pattern When to Use Single main.gowith inline config/providersSmall demos, simple services cmd/main.go+pkg/app/config.go+pkg/app/provider.goMedium services cmd/{service}/main.go+cmd/{service}/setup/+pkg/app/Multi-service projects Important: Each project should have a single centralized package (e.g.,
pkg/app/orapp/) that provides:- Config structs and
InitializeXxxConfigfunctions - Provider functions
- Embedded default YAML configuration files
This package serves as the single source of truth for dependency injection, whether for the project's own service startup or when other projects depend on this project's services.
Service Startup Principles:
cmd/is responsible for service startup, defining aSetupthat lists required providersmain.gocreates a lifecycle, loads config from the centralized package, then invokesSetup- For single service: one lifecycle with providers from the centralized package
- For multiple services in one cmd: define a parent lifecycle containing child lifecycles for each service. If service B depends on a value from service A after startup, use
lc.Resolve()on service A's lifecycle to obtain it
ASK USER to confirm the proposed structure before proceeding.
- Config structs and
-
Identify configuration items
Extract all configurable values:
- Database connection strings
- Server addresses/ports
- External service credentials (OAuth, API keys)
- Feature flags
- Timeouts and limits
⛔ Step 1 Checkpoint - MANDATORY
You MUST complete the following before proceeding to Step 2:
-
Output your analysis to the user:
## Step 1 Analysis Results ### Existing Structure - Entry point(s): [list files] - Existing config patterns: [describe] - Components to become providers: [list] - confx/lifecycle usage: [yes/no, describe] ### Proposed Directory Structure - Pattern: [single main.go | cmd + pkg/app | multi-service] - Files to create/modify: [list] ### Configuration Items Identified - [list all configurable values] -
If any issues found → Generate your proposed solution and include it in the output.
-
Continue to Step 2 immediately after outputting the analysis. Do NOT wait for user confirmation.
Step 2: Create Configuration Structure
-
Define Config struct
type Config struct { // Infrastructure configs (use qor5/x types when available) Log slogx.Config `confx:"log"` Database gormx.DatabaseConfig `confx:"database"` HTTP httpx.ServerConfig `confx:"http"` // Business configs // ... project-specific configs }Rules:
- Use
confx:"fieldName"tag (camelCase) - Use
usage:"description"tag for documentation - Use
validate:"required"for mandatory fields - Group infrastructure configs first, then business configs
- Use
-
Create embedded default config
Create
embed/default.yaml(or similar path based on project structure):log: level: info database: host: localhost port: 5432 # NO passwords or production values http: addr: ":8080"Rules:
- Provide complete defaults for local development
- NO sensitive information (passwords, API keys)
- NO production addresses
- Service should start with defaults alone (for local dev)
-
Implement config loader
//go:embed embed/default.yaml var defaultConfigYAML string func InitializeConfig(opts ...confx.Option) (confx.Loader[*Config], error) { def, err := confx.Read[*Config]("yaml", strings.NewReader(defaultConfigYAML)) if err != nil { return nil, errors.Wrap(err, "failed to load default config") } return confx.Initialize(def, opts...) }
⛔ Step 2 Checkpoint - MANDATORY
STOP. You MUST verify the following before proceeding to Step 3:
-
Config struct verification:
- All fields use
confx:"fieldName"tag (camelCase) - Required fields have
validate:"required"tag - Infrastructure configs grouped before business configs
- All fields use
-
Default config verification:
-
embed/default.yamlexists (or equivalent path) - NO sensitive information (passwords, API keys)
- NO production addresses
- Service can start with defaults alone
-
-
Output to user:
## Step 2 Verification ### Config Struct - File: [path] - Fields: [list with tags] ### Default Config - File: [path] - Contains sensitive info: NO ✅ / YES ❌ - Contains production addresses: NO ✅ / YES ❌
If any check fails → FIX before proceeding.
Step 3: Define Provider Functions
Each component should be a provider function following these rules:
-
Naming conventions:
- Single-function provider:
Setup{Component}- returns a single component - Multi-function provider:
Setup{Component}- uses[]anyslice containing multiple related providers - Serving-related:
Setup{Protocol}Serving- combines Listener + Server
- Single-function provider:
-
Provider function signature single-function provider:
// Dependencies as parameters, output as return value func SetupXXX(ctx context.Context, lc *lifecycle.Lifecycle, conf *Config, dep1 Type1, dep2 Type2) (*XXX, error)multiple-function provider: // use []any slice
var SetupXXX = []any{ func(conf *Config) *XXX { return &conf.XXX }, SetupXXX, }Serving combination:
// Serving combination var SetupXXXServing = []any{ SetupXXXListener, SetupXXXServer, }Internal service provider (registers service to existing server):
var SetupLedgerServices = []any{ // Extract service config from main config func(conf *Config) *ledger.Config { return &conf.Ledger }, // Construct internal service and register to server func(lc *lifecycle.Lifecycle, config *ledger.Config, grpcServer *grpc.Server) (*ledger.LedgerService, error) { service, err := ledger.NewService(config) if err != nil { return nil, err } ledgerv1.RegisterLedgerServiceServer(grpcServer, service) return service, nil }, }- Input: Dependencies declared via function parameters
- Output: Concrete type (NOT
any), with optionalerror - Accept
*lifecycle.Lifecycleif needs lifecycle hooks - Accept
context.Contextas first param if needed
-
Provider categories
Category Examples Notes Infrastructure Logger, ErrorNotifier Usually no lifecycle hooks Data Layer Database, Cache, MessageBus Register cleanup in lifecycle External Clients gRPC clients, HTTP clients May need connection management Business Services Domain services Depend on data layer Servers HTTP server, gRPC server HaveListener, HaveServe, Have middleware One-time Tasks Migrator, Seeder Return marker type after completion -
One-time task provider pattern (Migrate)
type Migrator struct{} func Migrate(ctx context.Context, db *gorm.DB) (*Migrator, error) { if err := db.AutoMigrate(AllModels()...); err != nil { return nil, errors.Wrap(err, "failed to migrate") } return &Migrator{}, nil }
- Return a marker type (e.g.,
*Migrator) so other providers can depend on it - Use dependency to enforce execution order (e.g., Seeder depends on Migrator)
-
Worker Service Rule
For background workers and message consumers that need lifecycle management:
var SetupWorkerService = []any{ // Extract worker-specific config from main config func(conf *Config) *WorkerConfig { return &conf.Worker }, // Create worker and register to lifecycle func(lc *lifecycle.Lifecycle, conf *WorkerConfig, db *gorm.DB, bus bus.Bus, conn ExternalConn) (*Worker, error) { // Create gRPC client inside provider when this is the terminal consumer client := externalv1.NewExternalServiceClient(conn) worker, err := NewWorker(conf, db, bus, client) if err != nil { return nil, err } // Register worker using goquex.NewWorkerService // For workers with Start(ctx) (WorkerController, error) signature lc.Add(goquex.NewWorkerService(worker.Start).WithName("worker-name")) return worker, nil }, }Key points:
- Use
[]anyslice to group config extraction and worker creation - Create gRPC client inside provider when it's the terminal consumer
- Use
goquex.NewWorkerServicefor workers that returnWorkerController - Use
.WithName()to identify the worker in logs
- Use
-
gRPC Connection vs Client rule
When a provider needs to call an external gRPC service:
- Define typed
Connfor each external service - Create
SetupXxxClientprovider ONLY when the client is shared by multiple providers - Create client inside provider when it's the terminal consumer
✅ Pattern A: Shared client (multiple consumers)
When multiple providers need the same client:
// Define typed connection type ConsentConn grpc.ClientConnInterface func SetupConsentConn(lc *lifecycle.Lifecycle, conf *Config) (ConsentConn, error) { return grpcx.SetupConnFactory("consent-conn")(lc, &conf.Consent) } // Create shared client provider - multiple providers depend on this func SetupConsentServiceClient(conn ConsentConn) consentv1.ConsentServiceClient { return consentv1.NewConsentServiceClient(conn) } // Multiple providers depend on the shared client func SetupServiceA(client consentv1.ConsentServiceClient) *ServiceA { ... } func SetupServiceB(client consentv1.ConsentServiceClient) *ServiceB { ... }✅ Pattern B: Terminal consumer (single consumer)
When only one provider needs the client, create it inside:
var SetupConsumerService = []any{ func(conf *Config) *ConsumerConfig { return &conf.Consumer }, func(lc *lifecycle.Lifecycle, conf *ConsumerConfig, conn ExternalConn) (*Consumer, error) { // Create client inside - this is the only consumer client := externalv1.NewExternalServiceClient(conn) consumer, err := NewConsumer(conf, client) // ... }, }❌ Anti-pattern: Creating unused shared client
// DON'T create SetupXxxClient if only one provider uses it func SetupExternalClient(conn ExternalConn) externalv1.Client { ... } // Only one consumer - should create client inside instead func SetupConsumer(client externalv1.Client) *Consumer { ... } - Define typed
-
Verify provider dependencies (Minimal Dependency Principle)
After creating a provider, verify that:
- Every provider's return type is used by at least one other provider in the lifecycle, OR
- The provider registers lifecycle hooks (OnStart/OnStop), OR
- The provider performs a one-time task (like migration)
If a provider's return type is not depended upon by any other provider and it doesn't register lifecycle hooks, it should NOT be added to the provider list.
Verification checklist:
For each new provider SetupXXX that returns *XXX: 1. Search the codebase: Is *XXX used as a parameter in any other provider? 2. Does SetupXXX call lc.Add() or lc.Append() to register lifecycle hooks? 3. Is SetupXXX a one-time task (migration, seeding)? If ALL answers are NO → Do NOT add this provider to the lifecycle✅ Valid reasons to add a provider:
- Its return type is a dependency of another provider
- It registers lifecycle hooks (starts a server, worker, etc.)
- It performs a one-time initialization task
❌ Invalid reason:
- "It might be useful later" - violates minimal dependency principle
- "Other services might need it" - add it when actually needed
⛔ Step 3 Checkpoint - MANDATORY
STOP. You MUST complete the following verification for EACH provider before proceeding to Step 4:
## Step 3 Provider Verification
For each provider, verify ALL applicable rules:
| Provider | Returns | Format | gRPC Client | Lifecycle |
|----------|---------|--------|-------------|-----------|
| (name) | (type) | (A/B) | (C/D/N/A) | (E/F/N/A) |
### Format Check:
- **A**: Uses `var SetupXXX = []any{...}` for multi-function providers ✅
- **B**: Uses `func SetupXXX(...) (Type, error)` for single-function providers ✅
### gRPC Client Check (if calls external gRPC):
- **C**: Shared client - has `SetupXxxClient` provider, multiple consumers depend on it ✅
- **D**: Terminal consumer - creates client inside provider, no other consumer needs it ✅
### Lifecycle Check (if needs lifecycle management):
- **E**: Uses `lc.Add(goquex.NewWorkerService(...))` for workers ✅
- **F**: Uses `lc.Add()` with custom Actor/Service for other cases ✅
### Minimal Dependency Check:
- [ ] Return type is used by another provider, OR
- [ ] Registers lifecycle hooks via `lc.Add()`, OR
- [ ] Performs one-time task (migration, seeding)
If ANY check fails → FIX before proceeding.
Step 4: Assemble Providers
-
Create setup variable
IMPORTANT: Setup MUST be a
varof type[]any, NOT a function.package setup import "github.com/yourproject/pkg/app" // Setup is a slice of providers - NOT a function var Setup = []any{ // 1. Infrastructure app.SetupI18N, app.SetupLogger, app.SetupErrorNotifier, // 2. External connections app.SetupCIAMConn, app.SetupConsentConn, app.SetupConsentServiceClient, // Shared client (multiple consumers) // 3. Servers app.SetupGRPCServing, app.SetupHTTPServing, // 4. Data layer app.SetupDatabase, app.SetupGoBus, // 5. Business services app.SetupLoyaltyServices, app.SetupLoyaltyServiceConn, app.SetupLoyaltyServiceClient, // Shared client // 6. Workers app.SetupConsumerService, // Terminal consumer, creates client inside }❌ Incorrect pattern:
// DON'T use function that calls lc.Provide manually func Setup(lc *lifecycle.Lifecycle, conf *Config) { lc.Provide(...) } -
Provider ordering rules
- Dependencies must come before dependents
- Group by category for readability
- One-time tasks before services that depend on migrated schema
⛔ Step 4 Checkpoint - MANDATORY
STOP. You MUST verify provider ordering and usage before proceeding to Step 5:
-
Dependency order check: For each provider, verify all its dependencies appear earlier in the list.
-
Unused provider check: Verify all provider functions defined in the package are included in the Setup slice. There should be NO unused provider functions.
-
Output to user:
## Step 4 Provider Order & Usage Verification | # | Provider | Dependencies | All deps appear earlier? | Included in Setup? | |---|----------|--------------|-------------------------|--------------------| | 1 | (name) | (list) | ✅/❌ | ✅/❌ | | 2 | (name) | (list) | ✅/❌ | ✅/❌ | ... ### Unused Provider Functions Check - All `Setup*` functions in package are used: ✅/❌ - Unused functions found: [list or "None"]
If ANY row has ❌ → Fix before proceeding:
- Dependency order issue → Reorder providers
- Unused provider → Either add to Setup or remove the function
Step 5: Create Entry Point
confx.Initialize internally creates a pflag.FlagSet and registers a -c/--config flag automatically. You do NOT need to manually create a FlagSet or define a confPath variable.
-
Standard main.go pattern (for long-running services)
package main import ( "context" "log" "os" "github.com/qor5/confx" "github.com/theplant/inject/lifecycle" "github.com/yourproject/cmd/myservice/setup" "github.com/yourproject/pkg/app" ) const envPrefix = "MYAPP_" func main() { confLoader, err := app.InitializeConfig(confx.WithEnvPrefix(envPrefix)) if err != nil { log.Fatalf("Failed to initialize config: %v", err) } if err := lifecycle.Serve(context.Background(), lifecycle.SetupSignal, func(ctx context.Context) (*app.Config, error) { return confLoader(ctx, "") }, setup.Setup, ); err != nil { log.Fatalf("Failed to serve: %v", err) os.Exit(1) } } -
Separate command pattern (e.g., migrate-only, one-shot tasks)
// cmd/migrate/main.go package main import ( "context" "log" "log/slog" "github.com/qor5/confx" "github.com/qor5/x/v3/gormx" "github.com/yourproject/pkg/app" ) const envPrefix = "MYAPP_" func main() { ctx := context.Background() confLoader, err := app.InitializeMigrateConfig(confx.WithEnvPrefix(envPrefix)) if err != nil { log.Fatalf("Failed to initialize config: %v", err) } conf, err := confLoader(ctx, "") if err != nil { log.Fatalf("Failed to load config: %v", err) } db, closer, err := gormx.Open(ctx, &conf.Database) if err != nil { log.Fatalf("Failed to open database: %v", err) } defer func() { if err := closer.Close(); err != nil { slog.Error("Failed to close database connection", "error", err) } }() if _, err = app.Migrate(ctx, db, &conf.Ledger); err != nil { log.Fatalf("Failed to run migrations: %v", err) } slog.Info("Migrations completed successfully") }
⛔ Step 5 Checkpoint - MANDATORY
STOP. You MUST verify the entry point before proceeding to Step 6:
-
main.go verification:
- Does NOT manually create
pflag.FlagSet(confx handles this internally) - Does NOT call
pflag.Parse()manually (confx handles this internally) - Does NOT define
confPathvariable (confx registers-c/--configautomatically) - Passes empty string to confLoader:
confLoader(ctx, "") - For services: includes
lifecycle.SetupSignalfor graceful shutdown - No business logic in main.go
- Does NOT manually create
-
Output to user:
## Step 5 Entry Point Verification - File: [path] - Manual pflag.FlagSet creation: NO ✅ / YES ❌ - Manual pflag.Parse() call: NO ✅ / YES ❌ - Manual confPath variable: NO ✅ / YES ❌ - Passes empty string to confLoader: ✅/❌ - Has lifecycle.SetupSignal (services only): ✅/❌/N/A - Business logic in main: NO ✅ / YES ❌
If ANY check fails → FIX before proceeding.
Step 6: Verify Setup
-
Build check // turbo
go build ./... -
Run with defaults
./cmd/myapp/myapp -
Run with config file
./cmd/myapp/myapp -c config.yaml
⛔ Step 6 Checkpoint - MANDATORY
You MUST verify the build succeeds AND the service starts correctly:
-
Run build check: // turbo
go build ./... -
If build fails → Fix the errors immediately, then re-run build check.
-
Run service startup check:
After successful build, start the service to verify it initializes correctly:
# Run the service in background, wait for startup, then check if it's running timeout 10s ./cmd/myapp/myapp & sleep 3 # Check if process is still running (not crashed) # If using HTTP server, can also curl health endpoint: # curl -f http://localhost:8080/healthz || echo "Health check failed"Verification criteria:
- Service starts without immediate crash
- No panic or fatal errors in startup logs
- If HTTP server: health endpoint responds
- If gRPC server: can establish connection
-
If startup fails → Analyze error logs, fix the issue, rebuild and re-test.
-
Output to user:
## Step 6 Final Verification - Build result: SUCCESS ✅ / FAILED ❌ - Service startup: SUCCESS ✅ / FAILED ❌ - Startup logs: [brief summary or "Clean startup"] - If failed initially, fixes applied: [describe fixes] -
Continue to mark task complete after successful build AND startup. Do NOT wait for user confirmation.
Configuration Priority (Low to High)
- Embedded defaults (
embed/default.yaml) - Config file (
-c/--config) - Environment variables (
{PREFIX}_FIELD_NAME) - Command-line flags
AI Agent Requirements
MUST DO
- Analyze existing project structure before proposing changes
- ASK USER to confirm directory structure proposal
- Use
confxfor all configuration (no scatteredos.Getenv()) - Each component as independent provider function
- Use
lifecyclefor dependency injection and lifecycle management - Provider functions return concrete types with explicit dependencies
- One-time tasks (migrate/seed) return marker types for dependency ordering
- Include
lifecycle.SetupSignalfor graceful shutdown - Setup must be
var Setup = []any{...}- not a function - Use
goquex.NewWorkerServicefor workers that returnWorkerController - Verify minimal dependency principle - only add providers that are actually used
MUST NOT
- Assume fixed directory structure without analysis
- Use global variables in providers
- Include business logic in main.go
- Call
pflag.Parse()manually (confx handles it) - Hardcode configuration values that should be configurable
- Use
func Setup(lc, conf)pattern - Setup must bevar Setup = []any{...} - Create custom Service wrapper types when
goquex.NewWorkerServiceor similar utilities exist - Create
SetupXxxClientproviders for terminal consumers - create client inside provider instead - Add providers whose return type is not depended upon - unless they register lifecycle hooks or perform one-time tasks
Common Infrastructure Packages
Core Packages
| Package | Import | Purpose |
|---|---|---|
confx |
github.com/qor5/confx |
Configuration management (YAML/env/flags) |
lifecycle |
github.com/theplant/inject/lifecycle |
Dependency injection and lifecycle management |
pflag |
github.com/spf13/pflag |
Command-line flag parsing |
Server & Network Packages
| Package | Import | Purpose |
|---|---|---|
httpx |
github.com/qor5/x/v3/httpx |
HTTP server setup and middleware |
grpcx |
github.com/qor5/x/v3/grpcx |
gRPC server/client connection and interceptors |
prottpx |
github.com/qor5/x/v3/prottpx |
Protobuf-to-HTTP bridge (JSON/protobuf over HTTP) |
healthz |
github.com/qor5/x/v3/healthz |
Health check (HTTP middleware & gRPC interceptor) |
Data & Messaging Packages
| Package | Import | Purpose |
|---|---|---|
gormx |
github.com/qor5/x/v3/gormx |
Database connection (GORM wrapper) |
gobusx |
github.com/qor5/x/v3/gobusx |
Message bus setup |
goquex |
github.com/qor5/x/v3/goquex |
Worker service (background jobs) |
Observability & Error Handling Packages
| Package | Import | Purpose |
|---|---|---|
slogx |
github.com/qor5/x/v3/slogx |
Structured logging (slog wrapper) |
errornotifierx |
github.com/qor5/x/v3/errornotifierx |
Error notification (e.g., Sentry) |
logtracing |
github.com/theplant/appkit/logtracing |
Request logging and distributed tracing |
Request Processing Packages
| Package | Import | Purpose |
|---|---|---|
normalize |
github.com/qor5/x/v3/normalize |
Request data normalization (trim, unicode) |
statusx |
github.com/qor5/x/v3/statusx |
gRPC status translation with i18n |
i18nx |
github.com/qor5/x/v3/i18nx |
Internationalization support |
hook |
github.com/qor5/x/v3/hook |
Middleware chaining utility |
Utility Packages
| Package | Import | Purpose |
|---|---|---|
errors |
github.com/pkg/errors |
Error wrapping with stack traces |
lo |
github.com/samber/lo |
Generic utility functions (slice, map) |