Design patterns
This page isn't a new layer: it's a map onto code that already exists: it gathers, in one place, the design patterns that recur across several of the kit's packages. Each entry carries the same three things: what it is, why the kit chose it (not "because it's best practice", but the concrete constraint that brought it in), and where to find it in production. None of these patterns are new or gpsystem-specific: they're standard tools from the Go ecosystem and software engineering at large; the links point to their canonical descriptions.
If you haven't yet, read Architecture first: it has the package map and the "centralize, don't abstract" principle that this page breaks down at the pattern level.
HTTP and API layer
Functional options
What: a constructor takes ...Option, where Option is a func(*internalStruct), so New/Run keeps a stable signature while accepting any number of optional parameters, extensible in a backward-compatible way.
Why here: the kit's New/Run/Setup functions have 5-10 optional settings (closer, middleware, validator, exporter...), and these are unreadable as positional variadic parameters, while a single Config struct would force every caller to see every field even when changing just one.
// server/fiberx/fiberx.go
type Option func(*options)
func WithCloser(name string, fn func(context.Context) error) Option {
return func(o *options) { o.closers = append(o.closers, app.Closer{Name: name, Fn: fn}) }
}
func WithMiddleware(mw ...fiber.Handler) Option {
return func(o *options) { o.middleware = append(o.middleware, mw...) }
}
Where: fiberx and chix (WithCloser, WithMiddleware, WithConfig), otelx (WithSpanExporter, nil-tolerant, see below), validate (WithTagNameFunc, WithRegister, directly on the wrapped *validator.Validate), paginate (WithDefaultPerPage, WithMaxPerPage).
External source: Dave Cheney: Functional options for friendly APIs.
otelx.WithSpanExporter(exp) simply skips when exp == nil, so telemetry.Setup can always pass sentryHandle.SpanExporter() (which is nil when Sentry is disabled) without an if branch.Middleware chain with a fixed order
What: the framework's middleware sequence (recovery, telemetry, CORS, ...) is built in a fixed, documented order before user middleware or routes run, the order itself is the behavior (e.g. recovery is always outermost, so it catches panics from every middleware below it).
// server/fiberx/fiberx.go: newApp
fiberApp.Use(recoverMiddleware)
if !o.withoutTelemetry {
fiberApp.Use(contribotel.Middleware())
}
fiberApp.Use(func(c fiber.Ctx) error {
c.SetContext(sentryx.WithRequestHub(c.Context()))
return c.Next()
})
fiberApp.Use(cors.New(cors.Config{ /* ... */ }))
for _, mw := range o.middleware {
fiberApp.Use(mw)
}
Where: fiberx and chix: recover → otel → sentry-hub → (dev logger) → cors → user middleware.
External source: the pattern itself is framework-agnostic; Fiber's middleware model: docs.gofiber.io.
Generic middleware adapter
What: a middleware factory written with Go generics, parameterized over a ~func(...) type family, so the same code fits every StrictHandlerFunc that oapi-codegen generates, without referencing the concrete generated type.
// server/chix/strict.go
func StrictValidator[H ~func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error)](
v *validate.Validator,
) func(f H, operationID string) H {
// ...
}
Where: chix.StrictValidator[H], policy.FiberEnforcer[H] / policy.HTTPEnforcer[H], auth.TokenIssuer[C jwt.Claims], all three apply the same pattern: the concrete (generated or caller-defined) type stays in the generic parameter, and the kit's code is written once.
External source: Go generics: official tutorial.
Context-injected dependency
What: a value (validator, identity, transaction) is placed into context under an unexported struct key, with a With*/*FromContext function pair, so a value passes from middleware to code below without a global variable or signature changes, and the key can't collide from outside the package.
// rbac/identity.go
type identityKey struct{}
func WithIdentity(ctx context.Context, id *Identity) context.Context {
return context.WithValue(ctx, identityKey{}, id)
}
func FromContext(ctx context.Context) *Identity {
id, _ := ctx.Value(identityKey{}).(*Identity)
return id
}
Where: rbac.WithIdentity/FromContext, chix validator injection (server/chix/chix.go), dbx/pg and dbx/bunx transaction carrying: see Transactions below.
External source: Go blog: Context and structs (on the correct use of context keys).
Service layer
What: one generated core/ package per module holds the shared business logic (plain structs with methods, rules written once, sentinel errors), and every surface's service/ package is a thin, audience-specific seam over it: it opens the transaction boundary, delegates shared rules to core, dispatches an event inside the transaction. There is no separate "domain model" layer in the DDD sense; the kit deliberately picks Fowler's Service Layer / Transaction Script pattern over full hexagonal/DDD layering. core is not a DDD domain layer: no aggregates, no value objects, no repository-abstraction ceremony, just the rule, in one place.
// internal/modules/<module>/surfaces/<surface>/service/service.go: the thin seam
type Service struct{ core *core.Service }
func (s *Service) Submit(ctx context.Context, req Request) (Ack, error) {
return s.core.Submit(ctx, req) // the shared rule lives in the module's core
}
// internal/modules/<module>/core/: the rule, written once
func (c *Service) Submit(ctx context.Context, req Request) (Ack, error) {
return c.tx.WithinTransaction(ctx, func(ctx context.Context) error {
// business rule + repo call + event dispatch, in one place
})
}
Why here: projects built with gpsystem are small-to-mid CRUD-adjacent backends, one handler chain per surface. Full DDD layering would mean an extra package, an extra interface, an extra constructor for every generated surface; without invariant-rich aggregates that's pure ceremony, and it contradicts the kit's "centralize, don't abstract" principle. What multiple entry points actually need (several surfaces sharing rules) is what the generated core provides; the per-surface service plays, in DDD terms, the application service role.
Growth path: when invariants grow, don't introduce a new layer: grow plain structs with methods inside the module's core/ package. What concerns only one surface may stay in that surface's service; the moment a second surface needs it, it moves to core. If a project genuinely needs more than that (real aggregates, several bounded contexts, a model per context), you have outgrown gpsystem: that is the territory of a hand-layered architecture, not of a generator.
Where: add surface generates the seam and, alongside a module's first surface, the core; the layer rule (http → service → core → repository) is on the Project structure page.
External source: martinfowler.com: Service Layer, martinfowler.com: Transaction Script.
Error handling
RFC 9457 Problem Details
What: every HTTP error response is a standardized JSON object (type, title, status, detail, instance) that a client can process uniformly from machine-readable code, instead of every endpoint inventing its own error shape.
// httperr/problem.go
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Code string `json:"code,omitempty"`
}
func (p *Problem) Error() string { /* ... */ }
Where: httperr.Problem, the full error model.
External source: RFC 9457: Problem Details for HTTP APIs.
Cascading errors.As mapping
What: a type switch that uses errors.As to try matching increasingly general error types in sequence (own *Problem → framework-specific bind error → generic *errs.Error → everything else), deciding the HTTP response at the first match.
// httperr/handler.go: mapProblem
var p *Problem
if errors.As(err, &p) { cp := *p; return &cp }
var be *fiber.BindError
if errors.As(err, &be) { return BadRequest(be.Error()) }
var ee *errs.Error
if errors.As(err, &ee) { /* extract status/code */ }
return New(http.StatusInternalServerError, "", detail)
Where: httperr.mapProblem: a single mapping point for both engines.
External source: Go blog: Working with errors.
Sentinel definition with a custom Is
What: an immutable, stackless Definition describes a code/public message/HTTP status as a package-level var; every error instantiated from it stamps a back-reference to the definition, and the error's Is(target) method compares that reference, so errors.Is(err, ErrX) matches at any depth in the chain, while every occurrence still gets its own call-site stack.
// errs/define.go + errs/errs.go
func Define(code string, attrs ...Attr) *Definition { /* ... */ }
func (e *Error) Is(target error) bool {
d, ok := target.(*Definition)
return ok && e.def != nil && e.def == d
}
Where: errs.Define: the full rationale and the shop ErrOutOfStock example are on the Error model page.
External source: Go blog: sentinel errors (Definition generalizes this into an attribute-carrying variant).
Lazy, once-per-chain stack capture
What: the full runtime.Callers stack is captured only at the deepest error in the chain (hasStack checks the cause first); every further wrap records only its own wrap site. So logging a deeply wrapped error doesn't get linearly more expensive with the number of wrap levels.
// errs/stack.go
const callerSkip = 4 // pinned by TestCaptureSkip
func newError(cause error, msg, full string) *Error {
e := &Error{msg: msg, full: full, err: cause, frame: caller(callerSkip)}
if !hasStack(cause) {
e.stack = callers(callerSkip)
}
return e
}
Where: errs: the Frames/Chain accessors read through this mechanism.
External source: pkg.dev: runtime.Callers.
Database
Narrow repository interface (sqlc-compatible)
What: repositories depend on a minimal, 3-method interface (Exec/Query/QueryRow) instead of the concrete pool, the interface is defined by the consumer's (the repository's) needs, not by the driver.
// dbx/pg/db.go
type DBTX interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
Why here: this interface is method-for-method identical to what sqlc generates, so a sqlc-generated repository accepts the kit's pg.DB unchanged, and joins the same context-carried transaction as hand-written code.
Where: dbx/pg.DBTX.
External source: Go, Effective Go: Interfaces (the principle of small, consumer-defined interfaces).
Unit of Work / transaction in context
What: a Transactor.WithinTransaction(ctx, fn) opens a transaction, places it into ctx, and commits after fn returns (or rolls back on error or panic). A nested call (when the context already carries a transaction) joins the existing one instead of opening a new one: nesting is flat, and commit/rollback always belongs to the outermost call.
// dbx/pg/transactor.go
func (t *transactor) WithinTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
if _, ok := TxFromContext(ctx); ok {
return fn(ctx) // join the existing one
}
tx, err := t.pool.Begin(ctx)
// ...
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.WithoutCancel(ctx)) // panic-safe
}
}()
if err := fn(ContextWithTx(ctx, tx)); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil { /* ... */ }
committed = true
return nil
}
Why here: the service layer doesn't want to know about pgx or bun, it just wants to say "these steps run in one transaction," with repositories (which depend on DBTX, not on the transaction) joining automatically.
Where: dbx.Transactor, implementations: dbx/pg and dbx/bunx.
External source: martinfowler.com: Unit of Work.
Opaque, tamper-resistant cursor
What: the "where I left off" state for keyset pagination is a base64url-encoded JSON envelope carrying the last row's sort keys and a sha256 fingerprint of the normalized ORDER BY, if a client replays the cursor against a different sort order, the fingerprint mismatch rejects it, instead of silently returning a wrong page.
// paginate/cursor.go
func sortFingerprint(sort string) string {
// normalized ORDER BY → short sha256 hex
}
if env.S != sortFingerprint(sort) {
return CursorParams{}, ErrInvalidCursor.New("cursor issued for a different sort order")
}
Where: paginate.CursorParams/NormalizeCursor: together with the generic Page[T]/CursorPage[T] wrappers.
External source: use-the-index-luke.com: keyset pagination (why keyset pagination beats OFFSET on large tables).
Async processing
Transactional outbox
What: the event isn't sent directly to the message broker but written to a database table in the same transaction as the business write, a separate relay process then forwards the committed rows to the queue. This closes the gap where the DB write happens but the event send is lost (or vice versa).
-- events/outbox/relay.go
SELECT id, event_id, event_name, payload, metadata, created_at
FROM outbox_events
WHERE published_at IS NULL
ORDER BY id
LIMIT $1
FOR UPDATE SKIP LOCKED
Why here: N worker replicas can concurrently poll the same table, FOR UPDATE SKIP LOCKED guarantees each replica claims a disjoint batch without blocking.
Where: events/outbox: the full dual-write problem and the shop PlaceOrder example live there.
External source: microservices.io: Transactional outbox.
At-least-once delivery + idempotent listener
What: the delivery chain (outbox → relay → queue → listener) is at-least-once end to end: retries and crash-recovery mean an event can reach a listener more than once. The contract is therefore explicit: listeners must be idempotent, using a stable per-event identifier (Meta.ID) as the dedup key.
// events/events.go
// Delivery is at-least-once end to end (outbox → relay → asynq → listener), so
// LISTENERS MUST BE IDEMPOTENT. Use Meta(ctx).ID as an idempotency key.
Where: events: Meta.ID and FanoutTasks's deterministic TaskID generation.
External source: microservices.io: Idempotent Consumer.
Redis-lease leader election
What: a single-holder lock in Redis (SETNX to acquire, a Lua script to renew that only allows PEXPIRE when the caller's identity still matches the lock value), so exactly one "leader" runs a given scheduled task out of a set of replicas.
// scheduler/lease.go
var renewScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end`)
Where: scheduler: Runner.lead renews at a third of the TTL, and steps down if it loses the lease.
External source: Redis docs: Distributed locks.
Security
Generics over the claims type
What: the JWT issuer and its middleware are generic over [C jwt.Claims], alongside the built-in DefaultClaims, a custom claims struct can be used with the same API and the same middleware guarantees (algorithm pinning, expiry checking).
// auth/token.go
type TokenIssuer[C jwt.Claims] struct{ cfg Config }
// auth/middleware.go
func MiddlewareFor[C jwt.Claims](issuer *TokenIssuer[C], toIdentity func(C) (*rbac.Identity, error)) fiber.Handler
Where: auth.TokenIssuer[C], MiddlewareFor[C].
External source: Go generics tutorial.
Higher-order authorization guard
What: the role and permission guard middleware (RequireRole, RequirePermission) both call a single requireFn(allowed func(*Identity) bool) fiber.Handler factory that owns the 401/403 decision: the only difference between them is the allowed predicate.
// rbac/rbac.go
func RequireRole(roles ...string) fiber.Handler {
return requireFn(func(id *Identity) bool { return id.HasRole(roles...) })
}
func requireFn(allowed func(*Identity) bool) fiber.Handler {
return func(c fiber.Ctx) error {
id := FromContext(c.Context())
if id == nil { return httperr.Unauthorized("authentication required") }
if !allowed(id) { return httperr.Forbidden("insufficient privileges") }
return c.Next()
}
}
Where: rbac.RequireRole/RequirePermission.
External source: Go blog: Function values (higher-order functions in Go).
Fail-closed policy enforcement
What: the enforcer middleware generated from the @permission/@policy TypeSpec decorators rejects a marked operation if there is no registered policy for it in the registry: missing configuration is an error, not a silent pass-through.
Where: policy.FiberEnforcer/HTTPEnforcer, the generation chain: Codegen pipeline.
External source: OWASP: Fail securely principle.
Codegen and tooling
Anchor-comment code injection
What: the generators never overwrite an existing file: they only insert new code before a line marked with // gpsystem:<name>, keeping the anchor line in place so the next run can find it again. Idempotency is guaranteed by a whitespace-normalized comparison: if gofmt later re-aligned the inserted block, the generator still recognizes it in normalized form and doesn't duplicate it.
// internal/generator/anchors.go
func normalizeLine(s string) string { return strings.Join(strings.Fields(s), " ") }
func InsertIntoContent(content, anchor, snippet string) (updated string, inserted bool, err error) {
// ... find the anchor line, adopt its indentation
if containsNormalizedBlock(content, block) {
return content, false, nil // already present, no duplication
}
// ... insert before the anchor
}
Where: Codegen pipeline: the // gpsystem:* anchors: the full anchor list and which command inserts what.
External source: the pattern is a cousin of the "marker comment" technique used by scaffolding-generator ecosystems in other languages; there's no direct Go-specific precedent, this is the kit's own solution.
Plan-then-apply generator writes
What: the file generator first computes the entire plan of which files would conflict with existing ones, and only then writes anything: without --force, a single conflict halts the whole run before a single byte hits disk. With --dry-run, only the plan is printed; nothing happens.
// internal/generator/engine.go: Apply
var conflicts []string
for _, f := range files {
if _, err := os.Stat(f.Path); err == nil {
conflicts = append(conflicts, f.Path)
}
}
if len(conflicts) > 0 && !e.Force {
return fmt.Errorf("refusing to overwrite existing files (use --force):\n %s", ...)
}
Where: internal/generator.Engine.Apply: every new/add command writes through this.
External source: the pattern is a cousin of Terraform's plan/apply split: terraform.io: Plan.
Spec-first codegen
What: the single source of truth for the API contract is a declarative specification (TypeSpec), from which OpenAPI is deterministically compiled, and from that a typed, "strict" server interface: a spec mismatch is a compile error, not a runtime surprise.
Where: Codegen pipeline: the full TypeSpec → OpenAPI 3.0 → oapi-codegen chain, with a per-engine template set.
External source: typespec.io: What is TypeSpec, oapi-codegen.
Architectural principles
Two patterns aren't tied to a single package: they explain how the kit as a whole is put together, and the Architecture page covers them in full; here only their name and link:
- "Centralize, don't abstract": the kit concentrates access to third-party dependencies (Fiber, pgx, bun, asynq) into individual packages, but their types flow freely through the public API; there's no kit-specific wrapper abstraction to learn.
- Interface segregation / dependency inversion at the three stated exceptions:
storage.Store,sentryx,mail.Mailer, wherever the kit does hide a dependency behind an interface, it's always for dispensability or replaceability, with a documented reason.
Where to next
- External dependencies lists every underlying library with its version and official documentation.
- Architecture gives the package map and the design principle this page breaks down at the pattern level.
- Error model and Codegen pipeline cover the two most deeply detailed pattern groups (errs/httperr, and anchor injection/spec-first codegen) in full.