PocketBase — Patterns#
Concurrency patterns#
Goroutine-based signal handling and graceful shutdown#
- Usage: 2 goroutines in
pocketbase.go, 2 more inapis/serve.go— the primary pattern for background concurrency - Example:
pocketbase.go:189— launches a goroutine that blocks on achan os.Signal(buffered, size 1); onSIGTERM/SIGINTcallsapp.Terminate()which triggersapp.OnTerminate()hooks. A second goroutine atpocketbase.go:198sends to adonechannel whenExecute()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
errgroupinapis/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 withgroup.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)inpocketbase.goandcore/base.go;make(chan struct{}, 1)inapis/batch.goandtools/filesystem; bufferedchan errorinapis/record_helpers.gofor async mail dispatch - Example:
apis/record_helpers.go:626—mailSent := 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:70and80— twosync.Poolinstances, one for*gzip.Writerand 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.Poolbecause 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 inplugins/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.Contextusages 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 %wfor wrapping at boundaries,errors.Newfor leaf errors; custom structured error types for HTTP responses - Error types defined:
tools/router/error.go—ApiError(structured HTTP error with code, message, and typeddatafield); alsoSafeErrorItem,SafeErrorParamsResolver,SafeErrorResolverinterfaces for safe exposure of error details to clientsapis/batch.go:524—BatchResponseErrorwraps 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 forerrors.Is/errors.Asinspection - Examples:
forms/record_upsert.go:263:fmt.Errorf("failed to rollback dry submit created record: %w", err)— standard wrap with contextforms/test_s3_filesystem.go:57:errors.New("S3 storage filesystem is not enabled")— sentinel-style for precondition failuresmigrations/1717233556_v0.23_migrate.go:31: consistentfmt.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
Configstruct. - 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 inmain.go, simple struct literals work well.
Dependency injection#
- Approach: Manual wiring —
core.Appis the single large interface passed explicitly to every subsystem - Evidence:
apis.NewRouter(app)— the entire HTTP layer receivescore.App- All route handlers receive a
*core.RequestEventwhich embedscore.Appas a field plugins.MustRegister(app, ...)— plugins attach to the app at startupapp.RunInTransaction(func(txApp core.App) error { ... })— transaction scoping creates a shallow copy ofBaseAppwrapping a transaction-awaredbx.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.Apphas ~150 methods) is intentional — acknowledged in godoc as not meant for external implementation. It avoids boilerplate at the cost of ISP compliance. Tests usecore.BaseAppdirectly 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— narrowingmodelto*core.Recordvs other model types for subscription matchingtools/router/router.go:84— narrowingRouterGroupchild to*Route[T]vs*RouterGroup[T]during mux constructiontools/router/router.go:272— narrowinghttp.ResponseWriterto check forWriteTracker/StatusTracker/http.Flusherinterface 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.