Architecture
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.
| Module | Import path | What it gives you | Docs section |
|---|---|---|---|
errs | github.com/gp-system/errs | stackable errors with a machine-readable code and a client-safe message; zero dependencies | Overview |
envconf | github.com/gp-system/envconf | typed configuration from env variables, with .env support | Overview |
httperr (+httperr/validate) | github.com/gp-system/httperr | RFC 9457 problem+json error responses, with a single mapping, plus struct-tag request validation | Overview |
dbx (+dbx/pg, dbx/bunx, dbx/seed) | github.com/gp-system/dbx | the database-agnostic Config/Transactor contract, pgx- and bun-backed implementations, and seeding | Overview |
logx (+logx/monolog) | github.com/gp-system/logx | the default slog logger's composition: console format, level filtering, fanout, plus a PHP/Monolog-style console formatter | Overview |
paginate | github.com/gp-system/paginate | offset and cursor pagination types and helpers | Overview |
queue | github.com/gp-system/queue | asynq + Valkey: task enqueueing, options, the event envelope | Overview |
events (+events/outbox, events/scheduler) | github.com/gp-system/events | events with listener fan-out; the transactional outbox; cron/interval scheduling with leader election | Overview |
auth (+auth/rbac, auth/policy) | github.com/gp-system/auth | JWT issuing and middleware, role/permission checks, per-request policies | Overview |
mail (+mail/smtp, mail/mjml) | github.com/gp-system/mail | a Mailer interface, a message builder, MJML template rendering | Overview |
notify (+notify/broadcast, notify/database) | github.com/gp-system/notify | multi-channel notifications addressed to a recipient (database, mail, live broadcast) | Overview |
storage (+storage/memory, storage/local, storage/storagetest) | github.com/gp-system/storage | object storage behind a Driver/Disk/Manager model, with in-memory and local-disk drivers | Overview |
minio-storage-driver | github.com/gp-system/minio-storage-driver | the S3-compatible storage.Driver, built on minio-go (declared package name: s3) | S3 |
telemetry (+telemetry/otelx, telemetry/sentryx) | github.com/gp-system/telemetry | the facade: telemetry.Setup boots logging, OpenTelemetry and Sentry in one call, depending on logx for the logging piece | Overview |
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):
| Package | Import path | What it gives you | Docs section |
|---|---|---|---|
app | github.com/gp-system/framework/app | the signal-driven graceful-shutdown process lifecycle every chassis sits on | Application lifecycle |
server | github.com/gp-system/framework/server | the chi-based HTTP engine: server.Config, server.Run, RegisterFunc, StrictValidator | The server core |
worker | github.com/gp-system/framework/worker | the background-processing chassis: asynq server + outbox relay + scheduler in one binary | Worker |
realtime | github.com/gp-system/framework/realtime | the WebSocket/SSE gateway that pushes notifications to connected clients, TopicAuth | Realtime |
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:
errssits 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 onerrsfor 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 plainnet/httpservice pulls in exactly that module's dependency footprint, nothing from the other 13. - The framework composes them on top.
server,workerandrealtimewire 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:
- 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.
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'sDriver/Disk/Managermodel 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 sameDrivercontract, andstorage/storagetestruns the same conformance suite against all of them.telemetry/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 oftelemetry/otelxalone (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 without the framework at all: a CLI tool, a Lambda, or thecmd/migratebinary uses the same pool and transactor with no HTTP layer and noserverimport.- Sentry is fully omittable: with
SENTRY_DSNempty thetelemetry/sentryxintegration is dead code. - OTel runs without exporters in dev mode (
OTEL_DEV_MODE=true), or is skipped entirely withWithoutTelemetry(). - Business code is decoupled from the transport: handler, service and repository code depends on generated interfaces and on
dbx/auth/eventstypes, never directly onchi; only the entry point and the module'ssurfaces/register.gowiring touch the HTTP layer. - 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 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
- 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 modules above, with code and external sources.
- External dependencies lists every underlying library with its version and official documentation.