github.com/alexfalkowski/go-service/v2 is an opinionated framework/library for building Go services with consistent wiring for configuration, DI, transports, telemetry, crypto, etc.
This repo is primarily a library of packages (no top-level cmd/ binary). Services built on top typically define their own main package elsewhere and import this module.
Most services are expected to be bootstrapped from go-service-template and to compose the high-level module bundles from this repository. That is the primary supported path. Lower-level package-by-package composition is still available, but it is an advanced mode and may require extra manual registration.
The framework is designed around dependency injection and uses Uber Fx (and Dig under the hood). Most subsystems expose Fx modules that you compose into your service.
If you are new to Fx, their docs/examples are worth reading first.
The module package exposes three top-level bundles:
module.Libraryfor shared foundations (env, compress, encoding, crypto, time, sync buffer-pool wiring, id)module.Serverfor server processes (Library + config, transports, telemetry, debug, health, etc.)module.Clientfor short-lived/batch/client processes (Library + config, telemetry, sql, hooks, etc.)
These bundles are the intended default for services generated from go-service-template. They handle the internal registration expected by the framework so most services do not need to wire lower-level transport or lifecycle helpers manually.
This repository is a library, so your binary is usually in another module. A typical main uses cli.Application and composes module bundles:
package main
import (
"github.com/alexfalkowski/go-service/v2/cli"
"github.com/alexfalkowski/go-service/v2/context"
"github.com/alexfalkowski/go-service/v2/module"
"github.com/alexfalkowski/go-service/v2/os"
)
func main() {
app := cli.NewApplication(func(commander cli.Commander) {
serve := commander.AddServer("serve", "Run the service", module.Server)
serve.AddInput("file:./config.yml") // adds the `-i` config input flag with this default
})
os.Exit(app.RunCode(context.Background()))
}Use app.RunCode(context.Background()) from main when exiting the process. It
returns os.ExitCodeSuccess on success, returns a requested non-zero shutdown
exit code such as os.ExitCodeServeFailure, and returns os.ExitCodeFailure
for other errors. Use app.Run(context.Background()) in tests or embedding code
that needs to inspect the returned error.
Services commonly expose two command shapes:
- Server: long-running daemon process
- Client: short-lived control/admin process
The framework uses acmd. Your service’s main typically wires Fx modules + commands.
This repo intentionally does not ship a ready-to-run
main— it provides the building blocks. In normal usage those building blocks are consumed throughgo-service-templateplusmodule.Server/module.Client, not by wiring every subsystem manually.
The repo is intentionally split between high-level service composition and lower-level reusable helpers:
module/exposes the opinionated Fx bundles (Library,Server,Client)config/defines the standard top-level config shape plus projections used by module wiring- feature packages such as
cache/,crypto/,database/sql/,feature/,telemetry/,time/, andid/provide config, constructors, and Fx modules for a subsystem net/...contains lower-level protocol helpers and reusable primitives (net/http,net/grpc, metadata/header helpers, gRPC health protocol aliases, andnet/server)transport/...contains the higher-level service transport layer: composed HTTP/gRPC stacks, policy middleware, operational endpoints, and transport-specific modulesinternal/test/contains the shared test world and fixtures used across packages
As a rule of thumb: if you want protocol primitives or shared helpers, start in net/...; if you want service wiring and middleware policy, start in transport/....
For most service authors, the right starting point is still the high-level module bundles rather than these lower-level packages directly.
The config decoder supports:
- JSON
- HJSON (
github.com/hjson/hjson-go/v4) - TOML (
github.com/BurntSushi/toml) - YAML (
go.yaml.in/yaml/v3)
Config input is routed by a flag called -i:
-
file:<path>Read config from a file at<path>; parser is selected from the file extension (.json,.hjson,.yaml,.yml,.toml). -
env:<ENV_VAR>Read config from env var<ENV_VAR>. The env var value must be formatted as:"<extension>:<base64-content>"Example format:
yaml:ZW52aXJvbm1lbnQ6IGRldmVsb3BtZW50Cg==Example commands:
# Linux (GNU base64) export SERVICE_CONFIG="yaml:$(base64 -w 0 < ./config.yml)" ./your-service serve -i env:SERVICE_CONFIG
# macOS/BSD base64 export SERVICE_CONFIG="yaml:$(base64 < ./config.yml | tr -d '\n')" ./your-service serve -i env:SERVICE_CONFIG
HJSON works the same way, for example
hjson:<base64-content>. -
Otherwise (no
file:/env:prefix), the decoder falls back to default lookup, searching for:<serviceName>.{yaml,yml,hjson,toml,json}in:
- executable directory
$XDG_CONFIG_HOME/<serviceName>/(viaos.UserConfigDir())/etc/<serviceName>/
At runtime, services typically decode into a struct (often embedding config.Config) and validate it using go-playground/validator.
The library provides a helper config.NewConfig[T] which:
- decodes into
*T - rejects an “empty” decoded value (guards against starting with a zero-value config)
- validates the decoded config
Example:
type AppConfig struct {
config.Config `yaml:",inline" json:",inline" toml:",inline"`
}
func loadConfig(decoder config.Decoder, validator *config.Validator) (*AppConfig, error) {
return config.NewConfig[AppConfig](decoder, validator)
}The canonical top-level config type is config.Config (in config/config.go). It contains:
debug,cache,crypto,feature,hooks,id,sql,telemetry,time,transport,environment
Most sub-configs are optional pointers. Conventionally, nil means disabled.
Many fields accept a source string rather than only a literal:
env:NAME→ read from environment variableNAME(fails ifNAMEis unset; resolves to an empty value ifNAMEis explicitly set to"")file:/path/to/thing→ read from filesystem- otherwise → treat as literal string
This is used for secrets and key material (TLS keys, HMAC keys, webhook secrets, SQL DSNs, etc).
Example:
hooks:
secret: env:WEBHOOK_SECRETTop-level environment is:
environment: developmentThis is an env.Environment value used to drive environment-specific behavior in services.
Compression kinds used by subsystems that support compression:
nonezstds2snappy
Encoding kinds used by subsystems that support encoding:
jsonhjsontomlyamlymlmsgpackprotopbprotobufprotobinpbbinprotojsonpbjsonprototextprototxtpbtxtgobplainoctet-streammarkdown
Notes:
plain,octet-stream, andmarkdownall map to the bytes passthrough encoder.- Protobuf binary/text/JSON kinds have multiple aliases; the list above reflects the built-in registry.
Cache configuration is defined in cache/config.Config:
cache:
kind: redis
compressor: zstd
encoder: json
max_size: 4MB
options:
url: env:CACHE_URLNotes:
- Built-in driver kinds in this repo are
redisandsync. kindis still wiring-dependent in practice: services can register additional drivers.max_sizelimits encoded cache values before compression, after compression, and after decompression. A zero value uses the default4MB.optionsis backend-specific and decoded asmap[string]any.
The feature.Config embeds client-side config (config/client.Config), so it supports:
addresstimeoutretrylimitertlstokenoptions
Example:
feature:
address: localhost:9000
timeout: 10s
retry:
backoff: 100ms
timeout: 1s
attempts: 3
tls:
cert: file:test/certs/client-cert.pem
key: file:test/certs/client-key.pem
ca: file:test/certs/rootCA.pem
server_name: localhostNotes:
- Presence enables the feature subsystem configuration-wise, but you still need to register an OpenFeature provider in your service wiring.
Configured via hooks.Config:
hooks:
secret: file:test/secrets/hookssecret is a source string.
Inbound verification checks Standard Webhooks signatures and timestamps, but
does not store or reject previously seen webhook ids. Receivers that perform
non-idempotent work should deduplicate or process idempotently using
Webhook-Id or the event id, backed by durable shared storage when running more
than one receiver instance.
Supported ID kinds:
uuidksuidnanoidulidxid
Config:
id:
kind: uuidThe runtime is enhanced with:
SQL root config is database/sql.Config, with Postgres under sql.pg.
Postgres config embeds common pool + DSN config (database/sql/config.Config), including master/slave DSNs and pool sizes.
module.Server and module.Client both include sql.Module, which currently wires PostgreSQL support via database/sql/pg.Module.
Enablement is presence-based: a nil sql block or a nil sql.pg block disables SQL wiring. When enabled, the pgx stdlib driver is registered under the name pg, master/slave DSNs are resolved using the source-string rules described above, OpenTelemetry database/sql stats metrics are registered, and the resulting pools are closed on lifecycle stop.
Example (with source strings for DSNs):
sql:
pg:
masters:
- url: env:PG_MASTER_DSN
slaves:
- url: env:PG_SLAVE_DSN
max_open_conns: 5
max_idle_conns: 5
conn_max_lifetime: 1hExample (literal DSN; not recommended for production secrets):
sql:
pg:
masters:
- url: postgres://user:pass@localhost:5432/dbname?sslmode=disable
max_open_conns: 10Health checks are based on go-health.
The framework provides Kubernetes-style endpoints:
/<name>/healthz— general serving health status/<name>/livez— liveness probe/<name>/readyz— readiness probe
Successful health responses return HTTP 200 with the plain-text body SERVING.
Missing or failing observers return HTTP 503 with the standard go-service error response.
These are modeled after Kubernetes API health endpoints.
Telemetry config root is telemetry.Config:
telemetry:
logger: ...
metrics: ...
tracer: ...Logging uses log/slog.
Supported built-in logger kinds:
jsontexttintotlp
telemetry:
logger:
kind: json
level: infotelemetry:
logger:
kind: text
level: infotelemetry:
logger:
kind: otlp
level: info
url: http://localhost:4318/v1/logs
headers:
Authorization: env:OTLP_LOGS_AUTHNotes:
headersvalues are source strings.- Telemetry header maps are resolved during config projection; unset
env:values and unreadablefile:values fail fast (panic during startup).
Supported metrics kinds:
prometheusotlp
telemetry:
metrics:
kind: prometheusWhen Prometheus is enabled on HTTP transport, metrics are exposed at /<name>/metrics.
telemetry:
metrics:
kind: otlp
url: http://localhost:9009/otlp/v1/metrics
headers:
Authorization: env:OTLP_METRICS_AUTHTracing supports OTLP exporter config:
telemetry:
tracer:
kind: otlp
url: http://localhost:4318/v1/traces
headers:
Authorization: env:OTLP_TRACES_AUTHNote:
- Current tracer wiring exports via OTLP/HTTP when tracer config is present.
- https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/runtime
- https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/host
- https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
- https://pkg.go.dev/go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc
- https://github.com/redis/go-redis/tree/master/extra/redisotel
- https://github.com/XSAM/otelsql
Token configuration is rooted at token.Config, usually nested under transport config as transport.http.token and/or transport.grpc.token (via the shared server-side transport config).
Supported token kind values:
jwtpasetossh
Access control is configured inside transport token config:
transport:
http:
token:
access:
model: file:./config/rbac.conf
policy: file:./config/rbac.csvThe model is based on Casbin RBAC: https://github.com/casbin/casbin/blob/master/examples/rbac_model.conf
Note:
access.modelandaccess.policyare resolved throughos.FS.ReadSource; usefile:for files,env:for environment-provided content, or literal content.
JWT config:
transport:
http:
token:
kind: jwt
jwt:
iss: my-service
exp: 1h
kid: my-key-idImportant behavior:
- JWT verification requires the
kidheader to exist and matchkidin config exactly. expis parsed as a Go duration string; invalid values can fail fast.
Paseto config:
transport:
http:
token:
kind: paseto
paseto:
iss: my-service
exp: 1hNote:
- The current PASETO implementation issues v4 public tokens using Ed25519 key material provided via wiring (not directly from
paseto.secret). If you want config-driven key material, load it via the crypto subsystem and wire signer/verifier appropriately.
SSH token verification keys are name-addressable and support rotation.
Verification-only example:
transport:
http:
token:
kind: ssh
ssh:
exp: 5m
keys:
- name: active
public: file:/keys/active.pubSigning + verification example:
transport:
http:
token:
kind: ssh
ssh:
exp: 5m
key:
name: active
private: file:/keys/active
keys:
- name: active
public: file:/keys/active.pub
- name: old
public: file:/keys/old.pubNotes:
ssh.keyis used for minting tokens (requires private key).ssh.keysis used for verification (public keys).ssh.expsets the token validity window; SSH keys remain long-lived, while generated tokens are short-lived.- The config does not enforce that the signing key name exists in the verification set; include it if you want round-trip.
Limiter config is transport/limiter.Config and is typically applied at transport level.
Supported key kinds (built-in):
user-agentipuser-idservice-method
Example:
transport:
http:
limiter:
kind: user-agent
tokens: 10
interval: 1sNote:
intervalis parsed as a Go duration string. Invalid values can fail fast.- The built-in limiter is an in-memory, per-process safeguard. Use it as a last resort and prefer an external edge, gateway, ingress, load balancer, or service-mesh limiter for production abuse protection.
- The
user-idkey uses the verified principal stored in metadata. For JWT/PASETO tokens this is the subject claim; for SSH tokens this is the verified key name. - The
service-methodkey uses HTTP route/path metadata or the gRPC full method name. - Server-side HTTP and gRPC limiters run after metadata extraction and token verification, so missing, malformed, or invalid authorization is rejected before it reaches the limiter. This is intentional; enforce quotas for those attempts with an external edge, gateway, ingress, load balancer, or service-mesh limiter.
Time config:
time:
kind: nts
address: time.cloudflare.comSupported kinds:
ntpnts
The transport layer provides higher-level wiring and middleware policy for communication in/out of the service.
At a high level:
transport/...contains the opinionated service transport layer: Fx wiring, composed HTTP/gRPC server and client stacks, retries, breakers, token middleware, health wiring, and related policy.net/...contains lower-level protocol helpers and reusable primitives such asnet/http,net/grpc,net/http/meta,net/grpc/meta,net/http/strings,net/grpc/strings,net/grpc/health,net/header, andnet/server.
Supported stacks include:
- gRPC (https://grpc.io/)
- HTTP REST abstraction (
net/http/rest) using content negotiation - HTTP RPC abstraction (
net/http/rpc) using content negotiation - HTTP MVC helpers (
net/http/mvc) - CloudEvents (https://github.com/cloudevents/sdk-go)
The HTTP REST and RPC helpers resolve encoders from the request Content-Type.
Built-in text/object payload media types include:
application/jsonapplication/hjsonapplication/yamlapplication/ymlapplication/tomlapplication/vnd.msgpackapplication/gobapplication/octet-streamtext/plaintext/markdown
Built-in protobuf-oriented media type aliases include:
application/protoapplication/pbapplication/protobufapplication/protobinapplication/pbbinapplication/protojsonapplication/pbjsonapplication/prototextapplication/prototxtapplication/pbtxt
Notes:
application/hjsonmaps to the built-inhjsonencoder kind.- Unknown or invalid request media types fall back to JSON selection.
text/erroris reserved for error responses and should not be sent by clients as a request content type.
The HTTP transport wraps the mux with net/http.NewNotFoundHandler so generated 404 responses can be rendered consistently while preserving other mux responses such as 405 Method Not Allowed.
- REST/RPC-style missing routes use
net/http/content.NotFoundHandler, which writes the standardstatus.WriteErrorresponse. - MVC missing routes can use
net/http/mvc.NotFoundHandlerto render the registered MVC not-found view when the request accepts HTML (Accept: text/html) or is an HTMX request (Hx-Request: true). - Routes that match and write their own status are not replaced by this mux-level not-found handler.
When an MVC controller returns an error, net/http/mvc.Route renders the returned view with a client-safe mvc.Error model. The model contains the HTTP status Code and safe client-visible Message.
The raw error string remains available to templates as mvcModelError metadata for compatibility. Rendering that metadata can expose diagnostic details, so prefer .Model.Message for client-visible error pages.
Transport config root is transport.Config:
transport.httpembedsconfig/server.Configtransport.grpcembedsconfig/server.Config
Minimal example:
transport:
http:
address: tcp://localhost:8000
timeout: 10s
grpc:
address: tcp://localhost:9000
timeout: 10sNotes:
- Address format should be
<network>://<address>(for exampletcp://:8000). - If address is omitted, defaults are
tcp://:8080(HTTP) andtcp://:9090(gRPC). max_receive_sizelimits inbound payload size. A zero value uses the default4MB.- For HTTP,
max_receive_sizeapplies per request body. For gRPC, it applies per inbound unary request and per inbound stream message. - MVC does not enforce its own body-size caps; supported HTTP server wiring applies
max_receive_sizebefore MVC handlers run, and go-service HTTP clients apply their configured response-size cap when reading responses.
Receive-limit example:
transport:
http:
max_receive_size: 2MB
grpc:
max_receive_size: 3MBWith low-level server options:
transport:
http:
address: tcp://localhost:8000
timeout: 10s
options:
read_timeout: 10s
write_timeout: 10s
idle_timeout: 10s
read_header_timeout: 10s
grpc:
address: tcp://localhost:9000
timeout: 10s
options:
keepalive_enforcement_policy_ping_min_time: 10s
keepalive_max_connection_idle: 10s
keepalive_max_connection_age: 10s
keepalive_max_connection_age_grace: 10s
keepalive_ping_time: 10sTLS config uses crypto/tls/config.Config and fields are source strings:
transport:
http:
tls:
cert: file:test/certs/cert.pem
key: file:test/certs/key.pem
ca: file:test/certs/rootCA.pem
grpc:
tls:
cert: file:test/certs/cert.pem
key: file:test/certs/key.pem
ca: file:test/certs/rootCA.pemSet ca on server TLS config to require and verify client certificates for mTLS. Set ca on client TLS
config to verify server certificates issued by the same local or private CA. server_name is only needed
on clients when the dial address differs from the certificate DNS name.
Important note:
- If you are using
go-service-templateor composing the standard bundles such asmodule.Server,module.Client, ortransport.Module, the required transport registration is handled for you by DI. - You only need to call transport-level
Register(...)functions yourself when you intentionally wire transports manually or compose lower-level packages outside the standard module graph. - If you are wiring server lifecycle manually, use
net/server.Register(...).
HTTP and gRPC metadata extraction intentionally trusts common forwarded IP headers/metadata such as X-Forwarded-For, X-Real-IP, CF-Connecting-IP, and True-Client-IP. Services that rely on extracted IPs for logging, policy, or rate limiting should only receive traffic through trusted edge infrastructure that strips or overwrites client-supplied forwarding headers.
gRPC server reflection is intentionally always registered by net/grpc.NewServer so internal tooling can discover services. Services that should not expose reflection publicly should restrict access with bind addresses, TLS/client authentication, ingress policy, firewall rules, or service-mesh authorization.
The transport client wrappers include optional circuit breakers:
-
HTTP breaker (
transport/http/breaker):- Scope is per
"<METHOD> <HOST>". - Default failure statuses are
>=500and429. - Transport errors are counted as failures.
- Failure status responses are still returned to callers (while breaker accounting records a failure).
- Scope is per
-
gRPC breaker (
transport/grpc/breaker):- Scope is per
fullMethod. - Default failure codes are
Unavailable,DeadlineExceeded,ResourceExhausted, andInternal. - Errors with other gRPC codes are treated as successful for breaker accounting.
- Scope is per
The crypto root config is crypto.Config and supports multiple key types. Most fields are source strings.
Example:
crypto:
aes:
key: file:test/secrets/aes
ed25519:
public: file:test/secrets/ed25519_public
private: file:test/secrets/ed25519_private
hmac:
key: file:test/secrets/hmac
rsa:
public: file:test/secrets/rsa_public
private: file:test/secrets/rsa_private
ssh:
public: file:test/secrets/ssh_public
private: file:test/secrets/ssh_privateNotes:
- AES keys must be 16/24/32 bytes after resolving the source string.
- RSA keys expect PKCS#1 PEM blocks (
RSA PUBLIC KEY/RSA PRIVATE KEY). - Ed25519 expects PKIX
PUBLIC KEYand PKCS#8PRIVATE KEYPEM blocks.
Debug server config:
debug:
address: tcp://localhost:6060
timeout: 10sEnable TLS:
debug:
tls:
cert: file:test/certs/cert.pem
key: file:test/certs/key.pem
ca: file:test/certs/rootCA.pemAll debug endpoints are namespaced by service name: /<name>/debug/....
GET http://localhost:6060/<name>/debug/statsvizhttps://github.com/arl/statsviz
GET http://localhost:6060/<name>/debug/pprof/
GET http://localhost:6060/<name>/debug/pprof/cmdline
GET http://localhost:6060/<name>/debug/pprof/profile
GET http://localhost:6060/<name>/debug/pprof/symbol
GET http://localhost:6060/<name>/debug/pprof/tracehttps://pkg.go.dev/net/http/pprof
GET http://localhost:6060/<name>/debug/fgprof?seconds=10https://pkg.go.dev/github.com/felixge/fgprof
GET http://localhost:6060/<name>/debug/psutilhttps://github.com/shirou/gopsutil
This repo generally follows the Uber Go Style Guide.
For local TLS fixtures:
This repo uses a bin/ git submodule for make targets.
git submodule sync
git submodule update --init
mkcert -install
make create-certs
make depIf submodule fetch fails, ensure GitHub SSH access is configured (.gitmodules uses git@github.com:... URLs).
make helpmake depmake dep runs:
go mod downloadgo mod tidygo mod vendor
Tests are run with -mod vendor, so after dependency changes run make dep before make specs.
Start required services:
make startStop them:
make stopRun unit tests with race + coverage:
make specsArtifacts:
- JUnit XML:
test/reports/specs.xml - Coverage profile:
test/reports/profile.cov
make lint
make fix-lint
make formatmake secmake benchmarks
make http-benchmarks
make grpc-benchmarks
make bytes-benchmarks
make strings-benchmarksmake coverage
make html-coverage
make func-coveragemake generatemake diagrams
make crypto-diagram
make database-diagram
make telemetry-diagram
make transport-diagramAll exported identifiers should have GoDoc comments, and each comment should start with the identifier name (or Deprecated:).
make kind=status encode-configusesbase64 -w 0(GNU style). On macOS/BSD usebase64 | tr -d '\n'.- If you enable transport TLS and wire transports manually (without
transport.Moduleor the higher-levelmodule.Serverbundle), call:transport/http.Register(fs)transport/grpc.Register(fs)
- Services built from
go-service-templatenormally do not need to call those registration helpers directly. - Shared metadata and header helpers live under
net/..., for example:net/http/metanet/grpc/metanet/headernet/server.Register




