PocketBase — Patterns#

Concurrency patterns#

Goroutine-based signal handling and graceful shutdown#

  • Usage: 2 goroutines in pocketbase.go, 2 more in apis/serve.go — the primary pattern for background concurrency
  • Example: pocketbase.go:189 — launches a goroutine that blocks on a chan os.Signal (buffered, size 1); on SIGTERM/SIGINT calls app.Terminate() which triggers app.OnTerminate() hooks. A second goroutine at pocketbase.go:198 sends to a done channel when Execute() returns, allowing the main goroutine to unblock.
  • Assessment: Idiomatic and minimal. No unnecessary abstraction. The done := make(chan bool, 1) pattern safely avoids blocking the goroutine if nobody reads it.

Fan-out with errgroup for SSE client broadcasting#

  • Usage: 4 uses of errgroup in apis/realtime.go, all for broadcasting events to chunked slices of connected SSE clients
  • Example: apis/realtime.go:226 — clients are split into chunks of 150 (clientsChunkSize), each chunk processed concurrently with group.Go(...). This allows parallel scanning of auth state across thousands of connected clients without spawning unbounded goroutines.
  • Assessment: Effective and bounded fan-out. The chunk size is hardcoded (noted as arbitrary in a comment), which is practical for the use case. errgroup provides clean error collection without custom boilerplate.

Channel-based oneshot signaling#

  • Usage: make(chan bool, 1) in pocketbase.go and core/base.go; make(chan struct{}, 1) in apis/batch.go and tools/filesystem; buffered chan error in apis/record_helpers.go for async mail dispatch
  • Example: apis/record_helpers.go:626mailSent := make(chan error, 1) lets a goroutine send a mail asynchronously while the HTTP response is written; the channel is non-blocking because it is buffered with capacity 1.
  • Assessment: Conservative use of channels — only for signaling and oneshot results. No complex channel pipelines. This matches PocketBase’s “single binary, single SQLite” simplicity philosophy.

sync.Pool for gzip writer recycling#

  • Usage: apis/middlewares_gzip.go:70 and 80 — two sync.Pool instances, one for *gzip.Writer and one for *bytes.Buffer
  • Example: Both pools are used within the gzip middleware closure; writers and buffers are returned to the pool after each request. The body limit middleware explicitly avoids sync.Pool because element sizes vary too much (apis/middlewares_body_limit.go:88).
  • Assessment: Correct, targeted use of sync.Pool. The inline comment explaining why the body limit middleware does NOT use a pool shows thoughtful reasoning about pool semantics (per-element size variance makes pooling inefficient).

Categories checked#

  • Worker pools: sync.Pool (gzip buffers); Goja JS runtime pool in plugins/jsvm (pre-warmed, bounded pool of runtimes)
  • Fan-out/fan-in: Fan-out via errgroup in SSE broadcasting
  • Pipeline processing: Hook chain (see below) — sequential, not parallel
  • Context cancellation: 197 context.Context usages throughout; passed into DB queries, HTTP handlers, SSE connections
  • Graceful shutdown: Signal goroutine + server.Shutdown(ctx) with timeout context (apis/serve.go:175)
  • Rate limiting: Token-bucket style via middlewares_rate_limit.go, applied globally and per-collection-route

Error handling#

  • Style: Mixed — primarily fmt.Errorf %w for wrapping at boundaries, errors.New for leaf errors; custom structured error types for HTTP responses
  • Error types defined:
    • tools/router/error.goApiError (structured HTTP error with code, message, and typed data field); also SafeErrorItem, SafeErrorParamsResolver, SafeErrorResolver interfaces for safe exposure of error details to clients
    • apis/batch.go:524BatchResponseError wraps per-subrequest errors in batch API responses
  • Wrapping approach: fmt.Errorf("failed to X: %w", err) is the dominant pattern in migrations and forms; preserves the error chain for errors.Is/errors.As inspection
  • Examples:
    • forms/record_upsert.go:263: fmt.Errorf("failed to rollback dry submit created record: %w", err) — standard wrap with context
    • forms/test_s3_filesystem.go:57: errors.New("S3 storage filesystem is not enabled") — sentinel-style for precondition failures
    • migrations/1717233556_v0.23_migrate.go:31: consistent fmt.Errorf("failed to fetch old settings: %w", err) pattern across all migration steps

Configuration pattern#

  • Approach: Plain config structs, not functional options. Each subsystem takes a dedicated Config struct.
  • Example: BaseAppConfig (core), ServeConfig (apis), jsvm.Config, migratecmd.Config, ghupdate.Config — all passed at construction time and not modified afterward.
  • Assessment: Straightforward and readable. No functional options were found (grep -rn 'func With\|type Option ' returned nothing). This trades the flexibility of per-field defaulting for simpler, explicit struct initialization. Given that PocketBase is primarily used as a library embedded in main.go, simple struct literals work well.

Dependency injection#

  • Approach: Manual wiring — core.App is the single large interface passed explicitly to every subsystem
  • Evidence:
    • apis.NewRouter(app) — the entire HTTP layer receives core.App
    • All route handlers receive a *core.RequestEvent which embeds core.App as a field
    • plugins.MustRegister(app, ...) — plugins attach to the app at startup
    • app.RunInTransaction(func(txApp core.App) error { ... }) — transaction scoping creates a shallow copy of BaseApp wrapping a transaction-aware dbx.Builder, which is then passed to the closure; hooks inside the transaction receive this scoped copy
  • Assessment: No DI framework (no wire, dig, or fx). The “fat interface” approach (core.App has ~150 methods) is intentional — acknowledged in godoc as not meant for external implementation. It avoids boilerplate at the cost of ISP compliance. Tests use core.BaseApp directly without mocking.

Other notable patterns#

Generic hook system (Go 1.18+)#

The most architecturally significant pattern in the codebase. tools/hook/hook.go defines:

type Hook[T Resolver] struct { ... }
type Handler[T Resolver] struct { ... }

Trigger constructs the handler chain by wrapping closures in reverse order, each closure capturing the next function as old:

for i := len(handlers) - 1; i >= 0; i-- {
    old := event.nextFunc()
    event.setNextFunc(func() error {
        event.setNextFunc(old)
        return handlers[i](event)
    })
}
return event.Next()

This is the same technique as http middleware chains (e.g., Negroni, Alice) but generalized to any event type. Each handler calls e.Next() to continue — identical to http.Handler chaining. The generic constraint [T Resolver] ensures the event type always has Next(). This single mechanism drives HTTP middleware, record lifecycle hooks, server lifecycle hooks, and JS extension points.

Struct embedding for hook event composition#

All lifecycle event types embed hook.Event to inherit the Resolver interface:

// core/events.go
type RecordRequestEvent struct {
    hook.Event
    // ...fields
}

This is the standard Go embedding pattern, but its systematic use across ~60+ event types makes it a defining architectural idiom. New events are created by embedding hook.Event and adding relevant fields; no code generation required.

Generic Store[K, V] — concurrent key-value cache#

tools/store/store.go:

type Store[K comparable, T any] struct {
    data    map[K]T
    mu      sync.RWMutex
    deleted int64
}

Used for in-memory caches throughout core.BaseApp. The deleted counter and ShrinkThreshold constant implement a lazy shrink — the map is only rebuilt when deleted exceeds the threshold, avoiding allocation spikes on frequent deletions.

Iterator pattern via generics#

apis/record_helpers.go:291:

type iterator[T any] struct { ... }
func (ri *iterator[T]) next() T { ... }

Used for paginated record fetching, encapsulating cursor state.

Registry pattern for migrations#

Every migration file in migrations/ calls core.SystemMigrations.Register(func(txApp core.App) error { ... }) in an init() function. The Register call appends a (up, down) function pair keyed by timestamp to a global MigrationsList. Migrations run in timestamp order during Bootstrap(). User migrations use the same mechanism via core.AppMigrations.Register(...).

Type switches for interface narrowing#

10 occurrences across the codebase. Primary uses:

  • apis/realtime.go:426 — narrowing model to *core.Record vs other model types for subscription matching
  • tools/router/router.go:84 — narrowing RouterGroup child to *Route[T] vs *RouterGroup[T] during mux construction
  • tools/router/router.go:272 — narrowing http.ResponseWriter to check for WriteTracker/StatusTracker/http.Flusher interface satisfaction

sync.RWMutex embedding in structs#

apis/middlewares_rate_limit.go:227 embeds sync.RWMutex directly in the rate limiter struct (not as a named field), enabling callers to lock the struct itself — a common but somewhat controversial Go idiom. Other uses (hook.go, store.go, settings_model.go) name the mutex field mu, which is the more idiomatic style.

goto for retry logic#

tools/hook/hook.go:75-79 uses goto DUPLICATE_CHECK to re-check for duplicate IDs after generating a new one. Rare in modern Go code; used here for a compact deduplication loop that would otherwise require a named bool flag.

No table-driven tests detected#

Despite being a well-tested project (~500k lines), the specific testCases/tt.name table-driven idiom was not found. Tests appear to use direct assertion style rather than table-driven loops.