Koncepciók

Design patternek

A kitben visszatérő tervezési minták katalógusa: kódpéldával, előfordulási hellyel és a kanonikus külső forrással.

Ez az oldal nem új réteg, hanem térkép a már meglévő kódhoz: azokat a tervezési mintákat gyűjti egy helyre, amik a kit több csomagjában is visszatérnek. Mindegyik mintánál ugyanaz a három dolog szerepel: mi ez, miért ezt választotta a kit (nem "mert jó gyakorlat", hanem a konkrét kényszer, ami idehozta), és hol keresd meg élesben. A minták nem újak vagy gpsystem-specifikusak: a Go-ökoszisztéma és a szélesebb szoftverfejlesztés bevett eszközei; a linkek a kanonikus leírásukra mutatnak.

Ha még nem olvastad, előbb az Architektúra oldal érdemes: ott van a csomagtérkép és a „centralizál, nem absztrahál" alapelv, aminek ez az oldal a részletezése.

HTTP és API-réteg

Functional options

Mi: egy konstruktor ...Option-t fogad, ahol Option egy func(*belsőStruct), így a New/Run szignatúrája stabil marad, miközben tetszőleges számú opcionális paraméter adható át, backward-compatible módon bővíthetően.

Miért itt: a kit New/Run/Setup függvényeinek 5-10 opcionális beállítása van (closer, middleware, validator, exporter...), és ezek variadic pozicionális paraméterként olvashatatlanok, egy Config structként pedig minden hívónak az összes mezőt látnia kéne akkor is, ha csak egyet akar módosítani.

// server/server.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 ...func(http.Handler) http.Handler) Option {
    return func(o *options) { o.middleware = append(o.middleware, mw...) }
}

Hol: a kit server csomagja (WithCloser, WithMiddleware, WithHTTPServer), a telemetry/otelx (WithSpanExporter, nil-toleráns, lásd lent), a httperr/validate (WithTagNameFunc, WithRegister, közvetlenül a becsomagolt *validator.Validate-en), a paginate (WithDefaultPerPage, WithMaxPerPage).

Külső forrás: Dave Cheney: Functional options for friendly APIs.

Egy variáns, ami a kitben is előfordul: az opció maga elnyeli a nil/üres esetet, hogy a hívó feltétel nélkül adhassa át. Az otelx.WithSpanExporter(exp) egyszerűen kihagyja exp == nil-nél, így a telemetry.Setup a sentryHandle.SpanExporter()-t (ami nil, ha a Sentry ki van kapcsolva) mindig átadhatja, if-ág nélkül.

Middleware lánc rögzített sorrenddel

Mi: a keretrendszer middleware-sorozata (recovery, telemetria, CORS, ...) fix, dokumentált sorrendben épül fel, mielőtt a felhasználói middleware vagy a route-ok jönnének, a sorrend maga a viselkedés (pl. a recovery mindig legkívül van, hogy minden lejjebbi middleware panicját is elkapja).

// server/server.go: newMux
mux.Use(recoverMiddleware)
if !o.withoutTelemetry {
    mux.Use(otelhttp.NewMiddleware(""))
}
mux.Use(func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        next.ServeHTTP(w, r.WithContext(sentryx.WithRequestHub(r.Context())))
    })
})
mux.Use(cors.Handler(cors.Options{ /* ... */ }))
for _, mw := range o.middleware {
    mux.Use(mw)
}

Hol: a kit server csomagja: recover → otel → sentry-hub → (dev logger) → cors → felhasználói middleware.

Külső forrás: a minta maga framework-független; a chi middleware-modellje: go-chi/chi.

Generikus middleware-adapter

Mi: Go generikusokkal írt middleware-factory, ami egy ~func(...) alakú típuscsaláddal paraméterezett, így ugyanaz a kód minden oapi-codegen által generált StrictHandlerFunc-hoz illeszkedik anélkül, hogy a konkrét generált típusra hivatkozna.

// server/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 {
    // ...
}

Hol: server.StrictValidator[H], policy.Enforcer[H], auth.TokenIssuer[C jwt.Claims], mindhárom ugyanazt a mintát alkalmazza: a konkrét (generált vagy hívó-definiált) típus a generikus paraméterben marad, a kit kódja pedig egyszer van megírva.

Külső forrás: Go generikusok: hivatalos tutorial.

Context-injektált függőség

Mi: egy érték (validator, identity, tranzakció) egy unexported struct kulcsú context-be kerül egy With*/*FromContext függvénypárral, így a middleware és a lentebbi kód között érték adódik át anélkül, hogy globális változó vagy a szignatúrák módosítása kellene, és a kulcs csomagon kívülről nem ütközhet.

// auth/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
}

Hol: rbac.WithIdentity/FromContext, a kit server-jének validator-injektálása (server/strict.go), dbx/pg és dbx/bunx tranzakció-hordozás: lásd Tranzakciók alább.

Külső forrás: Go blog: Context and structs (a context-kulcsok helyes használatáról).

Service layer

Mi: modulonként egyetlen, generált core/ csomag tartja a közös üzleti logikát (sima structok metódusokkal, egyszer leírt szabályok, sentinel-hibák), és minden surface service/ csomagja egy vékony, célközönség-specifikus varrat fölötte: tranzakcióhatárt nyit, a közös szabályokhoz a core-ba delegál, tranzakción belül eventet dob. Nincs külön "domain model" réteg a DDD értelmében; a kit a Fowler-féle Service Layer / Transaction Script mintát választja a teljes hexagonális/DDD-rétegzés helyett. A core nem DDD domain layer: nincsenek aggregátumok, value objectek és repository-absztrakciós ceremónia, csak a szabály, egy helyen.

// internal/modules/<modul>/surfaces/<surface>/service/service.go: a vékony seam
type Service struct{ core *core.Service }

func (s *Service) Submit(ctx context.Context, req Request) (Ack, error) {
    return s.core.Submit(ctx, req) // a közös szabály a modul core-jában él
}

// internal/modules/<modul>/core/: a szabály, egyszer leírva
func (c *Service) Submit(ctx context.Context, req Request) (Ack, error) {
    return c.tx.WithinTransaction(ctx, func(ctx context.Context) error {
        // üzleti szabály + repo-hívás + event dispatch, egy helyen
    })
}

Miért itt: a gpsystemmel épített projektek kis-közepes CRUD-közeli backendek, surface-enként egy handler-lánccal. Egy teljes DDD-rétegzés extra package-et, extra interfészt, extra konstruktort jelentene minden generált surface-hez, invariáns-gazdag aggregátumok nélkül ez tiszta ceremónia, és ellentmond a kit "centralizál, ne absztrahálj" elvének. Amit a több belépési pont ténylegesen igényel (több surface osztozik a szabályokon), azt a generált core adja; a surface service DDD-fogalmakkal az application service szerepét tölti be.

Növekedési út: ha nőnek az invariánsok, ne vezess be új réteget: növessz sima structokat metódusokkal a modul core/ csomagjában. Ami csak egy surface-t érint, az maradhat a surface service-ében; amint egy második surface-nek is kell, költözik a core-ba. Ha egy projekt ennél tényleg többet igényel (valódi aggregátumok, több bounded context, kontextusonként külön modell), azzal kinőtted a gpsystemet: az egy kézzel rétegzett architektúra terepe, nem a generátoré.

Hol: add surface generálja a seamet és az első surface mellé a core-t; a rétegszabály (http → service → core → repository) a Projektstruktúra oldalon.

Külső forrás: martinfowler.com: Service Layer, martinfowler.com: Transaction Script.

Hibakezelés

RFC 9457 Problem Details

Mi: minden HTTP-hibaválasz egy szabványosított JSON-objektum (type, title, status, detail, instance), amit a kliens gépi kódból egységesen tud feldolgozni, ahelyett, hogy minden endpoint saját hibaalakot találna ki.

// 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 { /* ... */ }

Hol: httperr.Problem, a teljes hibamodell.

Külső forrás: RFC 9457: Problem Details for HTTP APIs.

Kaszkádoló errors.As mapping

Mi: egy típus-switch, ami errors.As-sel sorban megpróbál egyre általánosabb hibatípusokra illeszteni (saját *Problem → egy kérés-dekódolási hiba → generikus *errs.Error → minden más), és az első találatnál dönt a HTTP-válaszról.

// httperr/handler.go: mapProblem
var p *Problem
if errors.As(err, &p) { cp := *p; return &cp }
var de *DecodeError
if errors.As(err, &de) { return BadRequest(de.Error()) }
var ee *errs.Error
if errors.As(err, &ee) { /* status/code kinyerése */ }
return New(http.StatusInternalServerError, "", detail)

Hol: httperr.mapProblem: egyetlen mapping-hely, a generált szerver hibahookjaiba kötve.

Külső forrás: Go blog: Working with errors.

Sentinel-definíció testreszabott Is-sel

Mi: egy immutable, stack nélküli Definition írja le a hiba kódját/publikus üzenetét/HTTP-státuszát csomagszintű var-ként; minden belőle példányosított hiba visszahivatkozik a definícióra, és a hiba Is(target) metódusa ezt a hivatkozást hasonlítja, így errors.Is(err, ErrX) a lánc bármely mélységén talál, miközben minden előfordulás a saját, hívás-helyi stacket kapja.

// 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
}

Hol: errs.Define: a teljes indoklás és a shop ErrOutOfStock példa a Hibamodell oldalon.

Külső forrás: Go blog: sentinel errors (a Definition ennek generalizálása egy attribútum-hordozó változatra).

Lusta, láncononkénti stack-capture

Mi: a teljes runtime.Callers stack csak a lánc legmélyebb hibájánál capture-ölődik (a hasStack ellenőrzi a cause-t); minden további wrap csak a saját wrap-helyét jegyzi fel. Így egy mélyen wrappelt hiba loggolása sem drágul lineárisan a wrap-szintek számával.

// errs/stack.go
const callerSkip = 4 // pinelve TestCaptureSkip-pel

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
}

Hol: errs: a Frames/Chain accessorok ezen a mechanizmuson keresztül olvasnak.

Külső forrás: pkg.dev: runtime.Callers.

Adatbázis

Szűk repository-interfész (sqlc-kompatibilis)

Mi: a repositoryk egy minimális, 3 metódusos interfésztől (Exec/Query/QueryRow) függenek a konkrét pool helyett, az interfészt nem a driver, hanem a fogyasztó (a repository) igénye definiálja.

// 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
}

Miért itt: ez az interfész metódusról metódusra azonos azzal, amit a sqlc generál, így egy sqlc-vel generált repository a kit pg.DB-jét változtatás nélkül elfogadja, és ugyanabban a context-hordozta tranzakcióban vesz részt, mint a kézzel írt kód.

Hol: dbx/pg.DBTX.

Külső forrás: Go, Effective Go: Interfaces (kis, fogyasztó-definiált interfészek elve).

Unit of Work / tranzakció a contextben

Mi: egy Transactor.WithinTransaction(ctx, fn) nyit tranzakciót, a tranzakciót a ctx-be teszi, és a fn lefutása után commitol vagy (hiba vagy panic esetén) rollbackel. Beágyazott hívás (ha a context már hordoz tranzakciót) a meglévőhöz csatlakozik ahelyett, hogy újat nyitna: a nesting lapos, a commit/rollback mindig a legkülső hívást illeti.

// 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) // csatlakozás a meglévőhöz
    }
    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
}

Miért itt: a service-réteg nem akar tudni pgx-ről vagy bunról, csak azt akarja mondani, hogy "ez a több lépés egy tranzakcióban fusson", és a repositoryk (amik a DBTX-től függenek, nem a tranzakciótól) automatikusan csatlakozzanak.

Hol: dbx.Transactor, implementációk: dbx/pg és dbx/bunx.

Külső forrás: martinfowler.com: Unit of Work.

Opaque, manipuláció-védett cursor

Mi: a keyset-lapozás "hol tartottam" állapota egy base64url-kódolt JSON-boríték, ami a legutolsó sor rendező kulcsait és egy sha256-fingerprintet hordoz a normalizált ORDER BY-ról, ha a kliens egy másik sorrendhez próbálja visszaküldeni a cursort, a fingerprint-eltérés miatt elutasításra kerül, ahelyett, hogy hibás oldalt adna vissza.

// paginate/cursor.go
func sortFingerprint(sort string) string {
    // normalizált ORDER BY → rövid sha256 hex
}
if env.S != sortFingerprint(sort) {
    return CursorParams{}, ErrInvalidCursor.New("cursor issued for a different sort order")
}

Hol: paginate.CursorParams/NormalizeCursor: a Page[T]/CursorPage[T] generikus wrapperekkel együtt.

Külső forrás: use-the-index-luke.com: keyset pagination (miért jobb a keyset-lapozás az OFFSET-nél nagy táblákon).

Aszinkron feldolgozás

Tranzakciós outbox

Mi: az esemény nem közvetlenül a message brokerbe kerül, hanem egy adatbázis-táblába, ugyanabban a tranzakcióban, mint az üzleti írás, egy külön relay-folyamat pedig áttolja a commitált sorokat a queue-ba. Ez zárja be azt a rést, ahol a DB-írás megtörténik, de az esemény-küldés elveszik (vagy fordítva).

-- 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

Miért itt: N worker-replika konkurrensen pollozhatja ugyanazt a táblát, a FOR UPDATE SKIP LOCKED garantálja, hogy minden replika diszjunkt batch-et foglal, blokkolás nélkül.

Hol: events/outbox: a teljes dual-write probléma és a shop PlaceOrder példája ott.

Külső forrás: microservices.io: Transactional outbox.

Legalább-egyszeres kézbesítés + idempotens listener

Mi: a kézbesítési lánc (outbox → relay → queue → listener) végig at-least-once: retry, crash-újrapróbálkozás miatt egy esemény többször is eljuthat a listenerhez. A szerződés ezért explicit: a listener legyen idempotens, egy stabil per-esemény azonosítót (Meta.ID) használva dedup-kulcsként.

// 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.

Hol: events: a Meta.ID és a FanoutTasks determinisztikus TaskID-generálása.

Külső forrás: microservices.io: Idempotent Consumer.

Valkey-lease leader election

Mi: egy egyetlen-birtokosú lock Valkeyben (SETNX a szerzéshez, egy Lua-script a megújításhoz, ami csak akkor engedi meg a PEXPIRE-t, ha a hívó azonosítója még mindig a lock-értékkel egyezik), így egy replika-halmazból pontosan egy "vezető" fut egy adott ütemezett feladatot.

// events/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`)

Hol: scheduler: a Runner.lead renewel a TTL harmadánál, és lemond a vezetésről, ha elveszti.

Külső forrás: Redis docs: Distributed locks.

Biztonság

Generikusok a claims-típus fölött

Mi: a JWT-kibocsátó és az Identify egy [C jwt.Claims] típusparaméterrel generikus, a beépített DefaultClaims mellett saját claims-struct is használható, ugyanazzal az API-val és ugyanazokkal a validációs garanciákkal (algoritmus-pinning, lejárat-ellenőrzés).

// auth/token.go
type TokenIssuer[C jwt.Claims] struct{ cfg Config }

// auth/identify.go
func Identify[C jwt.Claims](issuer *TokenIssuer[C], toIdentity func(C) (*rbac.Identity, error), authorization string) (*rbac.Identity, error)

Hol: auth.TokenIssuer[C], Identify[C].

Külső forrás: Go generikusok tutorial.

Magasabb-rendű authorizációs guard

Mi: a szerep- és permission-ellenőrző függvények (rbac.CheckRole, rbac.CheckPermission) mindketten egyetlen check(id *Identity, want []string, has func(*Identity, ...string) bool) error helpert hívnak, ami az unauthenticated/forbidden-döntést birtokolja: az egyetlen különbség a kettő között a has predikátum. A kit net/http guardjai (server.RequireRole, server.RequirePermission) ugyanezt az alakot alkalmazzák eggyel feljebb, egy közös requireFn fölött, ami a visszaadott errort Problemként rendereli.

// auth/rbac/rbac.go
func CheckRole(id *Identity, roles ...string) error {
    return check(id, roles, (*Identity).HasRole)
}
func check(id *Identity, want []string, has func(*Identity, ...string) bool) error {
    if id == nil {
        return ErrUnauthenticated
    }
    if !has(id, want...) {
        return ErrForbidden
    }
    return nil
}

Hol: rbac.CheckRole/CheckPermission, server.RequireRole/RequirePermission.

Külső forrás: Go blog: Function values (magasabb-rendű függvények Go-ban).

Fail-closed policy enforcement

Mi: a @permission/@policy TypeSpec-dekorátorokból generált enforcer middleware elutasít, ha egy megjelölt művelethez nincs regisztrált policy a registry-ben: a hiányzó konfiguráció hiba, nem hallgatólagos átengedés.

Hol: policy.Enforcer, a generálási lánc: Codegen pipeline.

Külső forrás: OWASP: Fail securely alapelv.

Codegen és tooling

Anchor-comment kód-injektálás

Mi: a generátorok soha nem írnak felül meglévő fájlt: kizárólag egy // gpsystem:<név> jelölésű sor elé illesztenek be új kódot, az anchor sort megtartva, hogy a következő futás is odataláljon. Az idempotenciát egy whitespace-normalizált összevetés biztosítja: ha a gofmt utólag újraigazította a beillesztett blokkot, a generátor a normalizált formában is felismeri, és nem duplikál.

// 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) {
    // ... anchor sor megkeresése, indentáció átvétele
    if containsNormalizedBlock(content, block) {
        return content, false, nil // már bent van, nincs duplikáció
    }
    // ... beillesztés az anchor elé
}

Hol: Codegen pipeline: a // gpsystem:* anchorok: a teljes anchor-lista és mit illeszt hozzájuk melyik parancs.

Külső forrás: a minta a scaffolding-generátorok más ökoszisztémáiban is elterjedt "marker comment" technika rokona; Go-specifikus előzmény nincs, a kit saját megoldása.

Plan-then-apply generátor-írás

Mi: a fájlgenerátor előbb teljes egészében kiszámolja, mely fájlok ütköznének meglévőkkel, és csak ez után ír bármit: --force nélkül egyetlen ütközés is az egész futást megállítja, mielőtt egy byte is a lemezre kerülne. --dry-run mellett a terv csak kiíródik, semmi nem történik.

// 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", ...)
}

Hol: internal/generator.Engine.Apply: minden new/add parancs ezen keresztül ír.

Külső forrás: a minta rokona a Terraform plan/apply kettősségének: terraform.io: Plan.

Spec-first codegen

Mi: az API-kontraktus egyetlen forrásigazsága egy deklaratív specifikáció (TypeSpec), amiből determinisztikusan fordul OpenAPI, abból pedig típusos, "strict" szerver-interfész: egy spec-eltérés fordítási hiba, nem futásidejű meglepetés.

Hol: Codegen pipeline: a teljes TypeSpec → OpenAPI 3.0 → oapi-codegen lánc, engine-enkénti template-készlettel.

Külső forrás: typespec.io: What is TypeSpec, oapi-codegen.

Architekturális alapelvek

Két minta nem egy csomaghoz köthető, hanem a kit egészének felépítését magyarázza, ezeket az Architektúra oldal tárgyalja részletesen, itt csak a nevük és a linkjük:

  • "Centralizál, nem absztrahál": a 3rd-party függőségekhez (chi, pgx, bun, asynq) való hozzáférés egy-egy csomagba koncentrálódik, de a típusaik szabadon átfolynak a publikus API-n; nincs kit-specifikus wrapper-absztrakció megtanulandó.
  • Interfész-szegregáció / dependency inversion a három kimondott kivételnél: storage.Store, sentryx, mail.Mailer, ahol a kit mégis interfész mögé rejt egy függőséget, ott ez mindig az elhagyhatóság vagy cserélhetőség miatt van, dokumentált indokkal.

Merre tovább

  • A Külső függőségek oldal minden mögöttes library-t felsorol verzióval és hivatalos dokumentációval.
  • Az Architektúra a csomagtérképet és a tervezési alapelvet adja, aminek ez az oldal a minta-szintű bontása.
  • A Hibamodell és a Codegen pipeline a két legrészletesebben tárgyalt minta-csoportot (errs/httperr, illetve anchor-injektálás/spec-first codegen) teljes hosszában kifejtik.
Copyright © 2026