Concepts

Architecture

The module map, the lifecycle of a service, and the principle that holds it together.

gpsystem is a modular framework: 14 standalone Go modules, each independently go get-able and usable in any Go program, plus a thin framework that composes them into a running service. Every module is useful, 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. The framework's 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.

Module map

Each module is its own repository, its own go.mod, versioned independently of the rest: nothing here requires the framework, and nothing here requires any other module on this list except where noted. The framework pins a given module to a specific commit in its own go.mod (a tagged release if the module has cut one yet, otherwise a commit on its main branch); see External dependencies for the exact versions.

ModuleImport pathWhat it gives youDocs section
errsgithub.com/gp-system/errsstackable errors with a machine-readable code and a client-safe message; zero dependenciesOverview
envconfgithub.com/gp-system/envconftyped configuration from env variables, with .env supportOverview
httperr (+httperr/validate)github.com/gp-system/httperrRFC 9457 problem+json error responses, with a single mapping, plus struct-tag request validationOverview
dbx (+dbx/pg, dbx/bunx, dbx/seed)github.com/gp-system/dbxthe database-agnostic Config/Transactor contract, pgx- and bun-backed implementations, and seedingOverview
logx (+logx/monolog)github.com/gp-system/logxthe default slog logger's composition: console format, level filtering, fanout, plus a PHP/Monolog-style console formatterOverview
paginategithub.com/gp-system/paginateoffset and cursor pagination types and helpersOverview
queuegithub.com/gp-system/queueasynq + Valkey: task enqueueing, options, the event envelopeOverview
events (+events/outbox, events/scheduler)github.com/gp-system/eventsevents with listener fan-out; the transactional outbox; cron/interval scheduling with leader electionOverview
auth (+auth/rbac, auth/policy)github.com/gp-system/authJWT issuing and middleware, role/permission checks, per-request policiesOverview
mail (+mail/smtp, mail/mjml)github.com/gp-system/maila Mailer interface, a message builder, MJML template renderingOverview
notify (+notify/broadcast, notify/database)github.com/gp-system/notifymulti-channel notifications addressed to a recipient (database, mail, live broadcast)Overview
storage (+storage/memory, storage/local, storage/storagetest)github.com/gp-system/storageobject storage behind a Driver/Disk/Manager model, with in-memory and local-disk driversOverview
minio-storage-drivergithub.com/gp-system/minio-storage-driverthe S3-compatible storage.Driver, built on minio-go (declared package name: s3)S3
telemetry (+telemetry/otelx, telemetry/sentryx)github.com/gp-system/telemetrythe facade: telemetry.Setup boots logging, OpenTelemetry and Sentry in one call, depending on logx for the logging pieceOverview

minio-storage-driver is a standalone module rather than a storage subpackage because it pulls in the minio-go S3 client as a real dependency, and only the projects that actually need S3 should pay for that; every subpackage above ships inside its parent module.

Framework chassis

github.com/gp-system/framework itself is a thin, four-package chassis plus the scaffolding CLI (cmd/gpsystem, see the CLI reference):

PackageImport pathWhat it gives youDocs section
appgithub.com/gp-system/framework/appthe signal-driven graceful-shutdown process lifecycle every chassis sits onApplication lifecycle
servergithub.com/gp-system/framework/serverthe chi-based HTTP engine: server.Config, server.Run, RegisterFunc, StrictValidatorThe server core
workergithub.com/gp-system/framework/workerthe background-processing chassis: asynq server + outbox relay + scheduler in one binaryWorker
realtimegithub.com/gp-system/framework/realtimethe WebSocket/SSE gateway that pushes notifications to connected clients, TopicAuthRealtime

The generators and templates live in the framework's internal/. Consumer projects only ever see their output, plus the TypeSpec→OpenAPI→server-code pipeline.

Layering: everything standalone, the framework on top

The 14 modules and the framework form a strict layering, not a tangle:

  • errs sits at the very bottom, with zero third-party dependencies. Nothing in this list depends on the framework, but several modules (httperr, dbx, auth, events, mail, notify) depend on errs for their own error values, the same way any of your own packages could.
  • Every module works standalone, in any Go program, with no framework installed at all: import "github.com/gp-system/dbx/pg" in a CLI tool, a Lambda, or a plain net/http service pulls in exactly that module's dependency footprint, nothing from the other 13.
  • The framework composes them on top. server, worker and realtime wire a chosen subset of the modules into a runnable process (config composition, lifecycle, routing), and the CLI generates the wiring code so you rarely write it by hand. Using the framework is a convenience, never a requirement: you can adopt any subset of the modules directly and skip the framework entirely, or start with the framework and drop down to a module's own API whenever the generated wiring doesn't fit.

This is why the installation guide and the sample app can both start from go get github.com/gp-system/<module>@latest: there is no private step, no replace directive, no monorepo checkout required for any of it.

The lifecycle of a service

The server package's server.Config carries the HTTP-layer settings (timeouts, CORS, body limit); the process lifecycle itself lives in the app package, and server.Run delegates to app.Run. The sequence for every gpsystem HTTP process:

Telemetry bootstrap

telemetry.Setup boots observability in one call: Sentry first (telemetry/sentryx, if SENTRY_DSN is set), then the OTel SDK (telemetry/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

server.Run comes up with the framework defaults wired in: the router itself writes RFC 9457 Problems for panics, 404 and 405 (via httperr); a recovered panic becomes an errs error whose stack points at the panic site; request validation runs in the generated strict pipeline (server.StrictValidator); around all that a named middleware stack (request id, OTel HTTP middleware, a per-request Sentry hub, the optional access log, CORS, body limit), then whatever you pass via WithMiddleware(...), or rearrange by name via WithStack(...).

Route registration

Your RegisterFunc callback mounts the modules: you get a chi.Router and work with the native chi API: groups, routes, 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.

For tests, server.New(cfg, opts...) returns the configured *chi.Mux without running it.

Centralize, don't abstract

The framework's design principle: access to external dependencies is concentrated in one 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: RegisterFunc receives a chi.Router
func Run(ctx context.Context, cfg server.Config, register server.RegisterFunc, 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

The chi, pgx, bun and asynq documentation, Stack Overflow answers and upstream sample code all apply to your project verbatim. You are not learning a framework-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 a module does hide the dependency behind an interface, each time for a stated reason:

  • storage's Driver/Disk/Manager model hides the concrete backend: your app code should not have to import the AWS SDK to upload a file, and the backend is swappable via configuration. This is the module that best shows the abstraction earning its keep: it isn't a theoretical seam, there are genuinely multiple real drivers behind it (in-memory for unit tests, local disk for a single-node deployment, S3-compatible for production), all satisfying the same Driver contract, and storage/storagetest runs the same conformance suite against all of them.
  • telemetry/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 telemetry/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 the framework at all: a CLI tool, a Lambda, or the cmd/migrate binary uses the same pool and transactor with no HTTP layer and no server import.
  • Sentry is fully omittable: with SENTRY_DSN empty the telemetry/sentryx integration is dead code.
  • OTel runs without exporters in dev mode (OTEL_DEV_MODE=true), or is skipped entirely with WithoutTelemetry().
  • Business code is decoupled from the transport: handler, service and repository code depends on generated interfaces and on dbx/auth/events types, never directly on chi; only the entry point and the module's surfaces/register.go wiring touch the HTTP layer.
  • 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 framework's inversion of control (Fowler) deliberately stops at the process lifecycle: no Module interface that a bootstrap discovers and orders; no framework-owned lifecycle hook inside business code; no runtime DI container resolving the dependency graph for you. That boundary is a commitment, not a disclaimer: Run(ctx, cfg, register) owns startup, shutdown and telemetry flush, and nothing past that boundary is framework-owned. It's what a container-free framework looks like, not a looser definition of "framework."

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 root

A business rule or entity used by several surfaces of one module → the module root package (the package is named after the module: callers write gallery.Service, news.New(repo)). 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.

Framework-level

Project-agnostic, and genuinely needed by a second project → promote it into a standalone module (or, if it's chassis-specific wiring, into the gpsystem framework itself).

A worked example of the whole ladder: the mail/mail/smtp modules are the transport (module-level in the sense that every project needs an SMTP client, but standalone: they carry zero project-specific knowledge). A module's recipient list and email templates are business content (module-level in the project: internal/modules/contact/mail/). Composed MAIL_* config lives in internal/platform/config. See Mail for the full example.

Where to next

Copyright © 2026