Concepts

Architecture

The kit's package map, the lifecycle of a service, and the principle that holds it together.

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

PackageWhat it gives you
errsstackable errors with a machine-readable code and a client-safe message (Error model)
envconftyped configuration from env variables, with .env support (Configuration)
appthe signal-driven graceful-shutdown process lifecycle every chassis sits on (Application lifecycle)

HTTP layer

PackageWhat it gives you
serverthe engine-agnostic core: the shared server.Config (HTTP server)
server/fiberxthe Fiber v3 engine (Fiber)
server/chixthe chi / net/http engine (chi)
httperrRFC 9457 problem+json error responses, with a single mapping (Error responses)
validatestruct-tag-based request validation (Validation)
paginateoffset and cursor pagination types and helpers (Pagination)

Security

PackageWhat it gives you
authJWT issuing and middleware (Authentication)
rbacrole- and permission-based access (RequireRole, RequirePermission) (RBAC)
policyper-request policies, fail-closed enforcer middleware (Policies)

Database & storage

PackageWhat it gives you
dbxthe shared dbx.Config and the Transactor interface (Database, Transactions)
dbx/pgpgx-based access: pool, context-resolving DB, transactor (pgx)
dbx/bunxthe bun query builder over the same pool and transaction semantics (bun)
storage (+storage/s3)object storage behind the storage.Store interface (Storage)

Async processing

PackageWhat it gives you
queueasynq + Redis: task enqueueing and options (Queue)
events (+events/outbox)events with listener fan-out; transactional outbox dispatcher and relay (Events, Outbox)
schedulercron/interval scheduling with replica-safe leader election (Scheduler)
workerthe background-processing chassis: asynq server + relay + scheduler in one binary (Worker)

Observability

PackageWhat it gives you
telemetrythe 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)
otelxpure OpenTelemetry SDK bootstrap: traces, metrics, an OTLP log bridge (OpenTelemetry)
sentryxthe optional Sentry integration, gated on SENTRY_DSN (Sentry)

Mail

PackageWhat it gives you
mail (+mail/smtp, mail/mjml)a Mailer interface, a message builder, MJML template rendering (Mail)

Tooling

PackageWhat it gives you
cmd/gpsystemthe 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.NewErrorHandler is the app's ErrorHandler; on chi, the router itself writes RFC 9457 Problems for panics, 404 and 405
  • panic recovery: on both engines a recovered panic becomes an errs error whose stack points at the panic site
  • validation: on Fiber, validate.New() is the app's StructValidator (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:

  1. shutdown with ShutdownTimeout: no new requests, in-flight requests finish
  2. WithCloser closers run in LIFO order (whatever you opened last closes first)
  3. 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:

OptionUse
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
For tests, 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.Store hides 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.
  • sentryx wraps sentry-go: Sentry is switchable from the environment (SENTRY_DSN empty = fully off, every hook a no-op), and nothing outside the telemetry facade imports it: a consumer of otelx alone (a CLI, a migration tool) never even links sentry-go into its binary.
  • mail's Mailer interface 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/pg work without server: a CLI tool or the cmd/migrate binary uses the same pool and transactor with no HTTP layer.
  • Sentry is fully omittable: with SENTRY_DSN empty the integration is dead code.
  • OTel runs without exporters in dev mode (OTEL_DEV_MODE=true), or is skipped entirely with WithoutTelemetry().
  • The engine is chosen per project (new project --engine fiber|chi, recorded in gpsystem.yaml); business code (handler, service, repository) is engine-independent, only the entry point, register.go and the generated gen/ package differ.
  • The DB implementation is decided by import: a repository imports dbx/pg (SQL) or dbx/bunx (query builder); the dbx.Transactor interface 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

Copyright © 2026