Architecture
The library half of gpsystem is not a monolithic framework but a collection of Go packages organized into eight capability groups. Each package is usable, and understandable, on its own; this page gives you the map: what lives where, what builds on what, and the principle the whole thing is assembled by. Its role in this sense is a microservice chassis (Richardson): cross-cutting infrastructure (lifecycle, HTTP layer, telemetry, error model) that you import as a library. There is no container and no facade invisibly stitching the parts together: whatever connects, connects in your main.go, in explicit code.
Package map
Foundations
| Package | What it gives you |
|---|---|
errs | stackable errors with a machine-readable code and a client-safe message (Error model) |
envconf | typed configuration from env variables, with .env support (Configuration) |
app | the signal-driven graceful-shutdown process lifecycle every chassis sits on (Application lifecycle) |
HTTP layer
| Package | What it gives you |
|---|---|
server | the engine-agnostic core: the shared server.Config (HTTP server) |
server/fiberx | the Fiber v3 engine (Fiber) |
server/chix | the chi / net/http engine (chi) |
httperr | RFC 9457 problem+json error responses, with a single mapping (Error responses) |
validate | struct-tag-based request validation (Validation) |
paginate | offset and cursor pagination types and helpers (Pagination) |
Security
| Package | What it gives you |
|---|---|
auth | JWT issuing and middleware (Authentication) |
rbac | role- and permission-based access (RequireRole, RequirePermission) (RBAC) |
policy | per-request policies, fail-closed enforcer middleware (Policies) |
Database & storage
| Package | What it gives you |
|---|---|
dbx | the shared dbx.Config and the Transactor interface (Database, Transactions) |
dbx/pg | pgx-based access: pool, context-resolving DB, transactor (pgx) |
dbx/bunx | the bun query builder over the same pool and transaction semantics (bun) |
storage (+storage/s3) | object storage behind the storage.Store interface (Storage) |
Async processing
| Package | What it gives you |
|---|---|
queue | asynq + Redis: task enqueueing and options (Queue) |
events (+events/outbox) | events with listener fan-out; transactional outbox dispatcher and relay (Events, Outbox) |
scheduler | cron/interval scheduling with replica-safe leader election (Scheduler) |
worker | the background-processing chassis: asynq server + relay + scheduler in one binary (Worker) |
Observability
| Package | What it gives you |
|---|---|
telemetry | the facade: telemetry.Setup boots all three in one call (Overview) |
logx (+logx/monolog) | the default slog logger's composition; a Monolog-style dev console format (Logging) |
otelx | pure OpenTelemetry SDK bootstrap: traces, metrics, an OTLP log bridge (OpenTelemetry) |
sentryx | the optional Sentry integration, gated on SENTRY_DSN (Sentry) |
| Package | What it gives you |
|---|---|
mail (+mail/smtp, mail/mjml) | a Mailer interface, a message builder, MJML template rendering (Mail) |
Tooling
| Package | What it gives you |
|---|---|
cmd/gpsystem | the scaffolding CLI: project, module, surface, handler and worker generators (CLI) |
The generators and templates live in the kit's internal/. Consumer projects only ever see their output, plus the TypeSpec→OpenAPI→server-code pipeline.
The lifecycle of a service
The server package itself only provides server.Config; the process lifecycle lives in the app package, and the engines (fiberx.Run and chix.Run) delegate to the same app.Run. The sequence is therefore identical regardless of engine:
Telemetry bootstrap
telemetry.Setup boots observability in one call: Sentry first (sentryx, if SENTRY_DSN is set), then the OTel SDK (otelx: resource, OTLP exporters selected by the standard OTEL_* env variables, W3C propagation), and finally the default slog logger (logx: OTLP bridge + console handler + Sentry handler in one fanout). With OTEL_DEV_MODE=true it runs without exporters, logging readable console output. Skip the whole step with the WithoutTelemetry() option.
App / router construction
The engine comes up with the kit defaults wired in:
- error rendering: on Fiber,
httperr.NewErrorHandleris the app'sErrorHandler; on chi, the router itself writes RFC 9457 Problems for panics, 404 and 405 - panic recovery: on both engines a recovered panic becomes an
errserror whose stack points at the panic site - validation: on Fiber,
validate.New()is the app'sStructValidator(c.Bind()validates automatically); on chi, validation runs in the generated strict pipeline (chix.StrictValidator) - the OTel HTTP middleware, a per-request Sentry hub, CORS and the body limit, then whatever you pass via
WithMiddleware(...)
Route registration
Your register callback mounts the modules: on Fiber you get a *fiber.App, on chi a chi.Router, and you work with the native API: groups, routes, engine middleware.
Listen + signal handling
The listen call runs in a goroutine; signal.NotifyContext watches SIGINT/SIGTERM. Cancelling the parent context also triggers shutdown, which is useful in tests.
Graceful shutdown
On signal, in strict order:
- shutdown with
ShutdownTimeout: no new requests, in-flight requests finish WithCloserclosers run in LIFO order (whatever you opened last closes first)- telemetry flushes last, so spans emitted by closers still export
A second signal kills the process immediately. Run returns nil on a clean shutdown.
The worker chassis (worker.Run) is built on exactly the same app.Run: instead of HTTP, it shuts down an asynq server, the outbox relay and the schedule runner in the same order. The Application lifecycle page describes the internals in detail.
Options
The two engines' option sets mirror each other:
| Option | Use |
|---|---|
WithCloser(name, fn) | register cleanup (pgx pool, queue consumer, ...) |
WithMiddleware(mw...) | app-level middleware after the kit defaults |
WithConfig(fn) (fiberx) / WithHTTPServer(fn) (chix) | escape hatch: mutate the fiber.Config / http.Server |
WithValidator(v) | custom validator (e.g. extra validation tags) |
WithoutTelemetry() | tests, or processes that configure OTel themselves |
fiberx.New(cfg, opts...) / chix.New(cfg, opts...) return the configured *fiber.App / *chi.Mux without running them.Centralize, don't abstract
The kit's design principle: access to external dependencies is concentrated in one kit package each, but their types flow freely through the public API. There is no "gpsystem router interface" and no "gpsystem query abstraction". The signatures openly carry the third-party types:
// server/fiberx: register receives a *fiber.App
func Run(ctx context.Context, cfg server.Config, register func(app *fiber.App) error, opts ...Option) error
// server/chix: register receives a chi.Router
func Run(ctx context.Context, cfg server.Config, register func(r chi.Router) error, opts ...Option) error
// dbx/pg: hands you a raw pgxpool.Pool and pgx types
func MustNewPool(ctx context.Context, cfg dbx.Config) *pgxpool.Pool
func (d *DB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
// dbx/bunx: hands you a *bun.DB over the same pool
func Open(pool *pgxpool.Pool) *bun.DB
// worker: the escape hatch hands you raw asynq
func WithMux(fn func(mux *asynq.ServeMux)) Option
This is deliberate. The Fiber, chi, pgx, bun and asynq documentation, Stack Overflow answers and upstream sample code all apply to your project verbatim. You are not learning a kit-specific wrapper API, you are learning the Go ecosystem's established libraries, curated in one place. The generated project, in this sense, is a service template (Richardson): runnable scaffolding you're handed and that's yours from that point on, not a runtime layer living above your app.
The three deliberate exceptions
In three places the kit does hide the dependency behind an interface, each time for a stated reason:
storage.Storehides the AWS SDK: your app code should not have to import the AWS SDK to upload a file, and the implementation (AWS, MinIO, RustFS) is swappable via env configuration.sentryxwraps sentry-go: Sentry is switchable from the environment (SENTRY_DSNempty = fully off, every hook a no-op), and nothing outside thetelemetryfacade imports it: a consumer ofotelxalone (a CLI, a migration tool) never even links sentry-go into its binary.mail'sMailerinterface fronts go-mail and mjml-go, so tests can swap in an in-memory mailer.
Swappable and omittable parts
The other side of centralizing is that the parts don't fuse together:
dbx/dbx/pgwork withoutserver: a CLI tool or thecmd/migratebinary uses the same pool and transactor with no HTTP layer.- Sentry is fully omittable: with
SENTRY_DSNempty the integration is dead code. - OTel runs without exporters in dev mode (
OTEL_DEV_MODE=true), or is skipped entirely withWithoutTelemetry(). - The engine is chosen per project (
new project --engine fiber|chi, recorded ingpsystem.yaml); business code (handler, service, repository) is engine-independent, only the entry point,register.goand the generatedgen/package differ. - The DB implementation is decided by import: a repository imports
dbx/pg(SQL) ordbx/bunx(query builder); thedbx.Transactorinterface and the context-carried transaction are shared.
Where the line is
The kit deliberately doesn't introduce a few things that would tip it toward framework territory (Fowler: Inversion of Control): no Module interface that a bootstrap discovers and orders; no kit-owned lifecycle hook inside business code; no runtime DI container resolving the dependency graph for you. If any of those showed up, the honest name from that point on would be "framework," not "toolkit"; this is a deliberate guardrail, not an accidental omission.
Shared code: a decision ladder
Every project eventually needs code used by more than one surface, or more than one module. Where it goes depends on how widely it's actually shared. It's a decision ladder, not a single rule:
Surface-local
Used by one surface only → keep it inside that surface's service package. Don't extract it just because it looks reusable; extract it when a second caller actually appears.
Module core
A business rule or entity used by several surfaces of one module → the module's generated core/ package. This is the default, generated home of shared rules: every module gets it alongside its first surface, and the surface services delegate to it. Its persistence counterpart is the module-level repository/: one persistence layer per module, shared by every surface.
Module-level
Not a business rule but content the module produces, used by the jobs/listeners units or by several surfaces → a named sibling package under internal/modules/<module>/, e.g. internal/modules/contact/mail/. Never call it shared/: name it for what it provides, the same rule that applies to any Go package (see Design patterns).
App-wide
Used by two or more modules, or by cmd/* wiring itself → internal/platform/<name>/. This is the only app-wide shared location a generated project has; there is no pkg/ and no internal/pkg/ (see Project structure). internal/platform may hold: env-config composition, infrastructure adapters and factories, framework glue, small cross-cutting technical helpers (request language, request ID). It must not hold: business entities, business rules, or module-specific templates and payloads; that content stays at the module rungs, further down this ladder. As at every other level, the naming rule holds: no util, common, helpers or shared.
Kit-level
Project-agnostic, and genuinely needed by a second project → promote it into the gpsystem kit itself. The kit plays the role Go's own layout guides call a foundation/pkg layer: code with zero project-specific knowledge. Promotion needs a second real consumer: promoting on the first use freezes an app-specific decision into the kit's public API before it's actually general.
A worked example of the whole ladder: the kit's mail/mail/smtp packages are the transport (kit-level: every project needs an SMTP client). A module's recipient list and email templates are business content (module-level: internal/modules/contact/mail/). Composed MAIL_* config lives in internal/platform/config. See Mail for the full example.
Where to next
- The shop sample app shows how all of this composes into a project: entry points, worker, events.
- Application lifecycle opens up the inside of
app.Run. - Error model follows the
errs→httperrchain end to end. - Codegen pipeline takes you from TypeSpec to generated server code.
- Design patterns catalogs the recurring design patterns across the packages above, with code and external sources.
- External dependencies lists every underlying library with its version and official documentation.