| name | go-service |
| description | Production Go service conventions — project layout, config, slog logging, context propagation, graceful shutdown, error wrapping, plain-constructor DI, and table-driven tests. Use when building or reviewing a Go backend service or worker; triggers when the repo contains go.mod plus a long-running-process signal (HTTP/queue/DB usage, cmd/ layout), or when the user asks how to structure a Go service. For TUIs use go-tui; for desktop apps use wails3 — this skill's config/logging/error conventions still apply to their Go code. |
Go Service
Target Go 1.22+. Standard library first; add dependencies only for real gaps (pgx, sqlc, otel).
Project layout
Organize internal/ by domain, not by layer. Do not create handlers/, models/, services/ buckets.
cmd/api/main.go # wiring only: config, logger, deps, server, shutdown
internal/user/ # user.go (types + logic), store.go, handler.go
internal/billing/
internal/platform/ # cross-cutting: db pool, httpserver helpers
cmd/<app>/main.go stays thin: load config, build dependencies, run. No business logic.
- Avoid
pkg/ unless code is genuinely imported by other repos. internal/ is the default.
- One package per domain concept; a package named after what it provides (
user), not what it contains (utils, helpers, common).
Config
One Config struct, loaded from env once at startup, passed down explicitly. Fail fast on missing required values — never read os.Getenv deep in business code.
type Config struct {
Port string
DatabaseURL string
LogLevel slog.Level
ShutdownGrace time.Duration
}
func Load() (Config, error) { }
Plain stdlib parsing is fine; envconfig/caarlos0/env acceptable if the struct grows large. No config singletons, no viper for a plain service.
Logging: log/slog
- Create one
*slog.Logger in main (slog.NewJSONHandler(os.Stdout, ...) in prod), inject it via constructors.
- Key-value pairs, not formatted strings:
logger.Info("user created", "user_id", id) — never logger.Info(fmt.Sprintf(...)).
- Use
logger.With("component", "billing") for per-component loggers; use InfoContext/ErrorContext so handlers can extract trace IDs from ctx.
- Log errors once, at the top where they're handled — not at every layer they pass through.
Context propagation
- First parameter of every function that does I/O:
ctx context.Context. No exceptions, no ctx stored in structs.
- Honor cancellation in loops and workers:
select { case <-ctx.Done(): return ctx.Err() ... }.
- Deadlines belong to callers: wrap outbound calls with
context.WithTimeout at the call site.
context.WithValue only for request-scoped metadata (request ID, auth principal) with unexported key types — never for passing dependencies.
Graceful shutdown
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx); err != nil {
slog.Error("service exited", "err", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}
Put logic in run(ctx) error so it's testable and defers actually run; main only handles exit codes.
Errors
- Wrap with context at each boundary:
fmt.Errorf("fetching user %s: %w", id, err). Lowercase, no "failed to" chains, no punctuation.
- Check with
errors.Is (sentinels) and errors.As (typed), never string matching or == on wrapped errors.
- Sentinel errors for conditions callers branch on:
var ErrNotFound = errors.New("not found") exported from the domain package.
- Typed errors only when callers need structured data (field name, code): define a struct with
Error() string, retrieve via errors.As.
- Return errors; don't log-and-return (double logging) and don't panic for expected failures.
Dependency injection
Plain constructors, no DI frameworks (no wire, fx, dig). Accept interfaces, return concrete types. Define the interface where it's consumed, and keep it minimal:
type UserStore interface {
GetUser(ctx context.Context, id string) (User, error)
}
func NewService(store UserStore, logger *slog.Logger) *Service {
return &Service{store: store, logger: logger}
}
Wire everything explicitly in main.go/run(). If wiring gets long, that's fine — it's boring, greppable code.
Tests
Table-driven with subtests; name cases, run parallel where safe:
func TestParseAmount(t *testing.T) {
tests := []struct {
name string
in string
want int64
wantErr error
}{
{name: "valid", in: "10.50", want: 1050},
{name: "negative", in: "-1", wantErr: ErrInvalidAmount},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseAmount(tt.in)
if !errors.Is(err, tt.wantErr) { t.Fatalf("err = %v, want %v", err, tt.wantErr) }
if got != tt.want { t.Errorf("got %d, want %d", got, tt.want) }
})
}
}
Use t.Fatalf for setup/precondition failures, t.Errorf for assertions. Hand-write small fakes against your consumer-side interfaces instead of mock frameworks.
Pitfalls
- Goroutine leaks: every
go func() needs a defined exit — ctx cancellation, closed channel, or WaitGroup joined at shutdown. A goroutine reading from a channel nobody closes leaks forever.
- Ignoring ctx cancellation: long loops, retries, and
time.Sleep must check ctx.Done() (use time.After in a select, or context.WithTimeout).
- Global state: no package-level DB pools, loggers, or config vars. Everything through constructors. Package-level
sync.Once singletons hide dependencies and break tests.
- init() abuse:
init() only for cheap, infallible setup (registering encodings). Never for I/O, env reading, or anything that can fail — you can't return an error and ordering is implicit.
- Capturing loop variables in goroutines is fixed in Go 1.22+, but still pass values as args for clarity.
References
references/architecture.md — read when adding a database layer: layered architecture, repository pattern, sqlc + pgx setup and transaction handling.
references/observability.md — read when setting up logging/metrics/tracing beyond basics: slog handler patterns, request-scoped logging, OpenTelemetry pointers.