PocketBase — Architecture#

Architectural style#

Event-driven Plugin Framework (Microkernel variant)

PocketBase is a monolith in deployment terms (one binary, one SQLite file), but internally it is structured as a microkernel: the core.App interface is the kernel, and every subsystem — including the HTTP server, plugins, and JavaScript runtime — attaches to it by registering event hook handlers. There is no traditional service/repository layer separation; instead, all cross-cutting concerns (validation, file handling, auth, realtime) are wired through a typed, priority-ordered hook chain.

Evidence:

  • core.BaseApp holds ~60 typed hook.Hook[T] fields — one per lifecycle event (bootstrap, record create/update/delete, HTTP request, mailer send, etc.)
  • apis.Serve() fires app.OnServe() to let plugins attach routes before the listener starts
  • plugins/jsvm registers Go-side hooks that evaluate JavaScript when the hook fires — the entire JS extension system is just another set of hook handlers

Component diagram (textual)#

┌──────────────────────────────────────────────────────────────────────────┐
│  examples/base/main.go                                                   │
│  (binary entry point)                                                    │
│  pocketbase.New() → plugins.Register() → app.Start()                    │
└───────────────────────────────┬──────────────────────────────────────────┘
                                │ embeds
                    ┌───────────▼────────────┐
                    │  pocketbase.PocketBase  │  (library facade / CLI wiring)
                    │  embeds core.App        │
                    │  RootCmd *cobra.Command │
                    └───────────┬────────────┘
                                │ delegates to
          ┌─────────────────────▼──────────────────────┐
          │           core.App (interface)              │
          │           core.BaseApp (implementation)     │
          │                                            │
          │  • SQLite (data DB + aux DB, dual pools)   │
          │  • Settings (stored in _params table)      │
          │  • ~60 hook.Hook[T] fields                 │
          │  • Cron scheduler                          │
          │  • SubscriptionsBroker (SSE)               │
          │  • Store[string,any] (in-memory KV)        │
          └──────┬──────────┬──────────────────────────┘
                 │          │
    ┌────────────▼──┐   ┌───▼────────────────┐
    │  apis/        │   │  plugins/           │
    │  HTTP layer   │   │  jsvm (Goja JS)     │
    │  route        │   │  migratecmd (CLI)   │
    │  registration │   │  ghupdate (self)    │
    └──────┬────────┘   └────────────────────┘
           │ uses
    ┌──────▼────────────────────────────────────────────┐
    │  tools/  (utility micro-packages)                 │
    │  hook · router · store · cron · subscriptions     │
    │  filesystem · mailer · auth · search · security   │
    │  types · inflector · tokenizer · routine · logger │
    └───────────────────────────────────────────────────┘
           │ embedded
    ┌──────▼───────┐
    │  ui/dist/    │  (pre-built Svelte Admin UI, go:embed)
    └──────────────┘

Core components#

core.App / core.BaseApp#

  • Package: github.com/pocketbase/pocketbase/core
  • Responsibility: The central kernel. Owns all infrastructure resources: two pairs of SQLite connection pools (concurrent + nonconcurrent, for data and aux DBs), in-memory settings, cron scheduler, SSE subscription broker, and the full set of typed lifecycle hooks.
  • Key types: App (interface, ~150 methods), BaseApp (concrete impl), BaseAppConfig, Settings, TxAppInfo
  • Dependencies: tools/hook, tools/cron, tools/store, tools/subscriptions, tools/filesystem, tools/mailer, tools/logger, pocketbase/dbx

tools/hook#

  • Package: github.com/pocketbase/pocketbase/tools/hook
  • Responsibility: The extensibility mechanism. A generic Hook[T Resolver] holds a priority-sorted list of Handler[T] structs. Handlers must call e.Next() to continue the chain — exactly like http middleware. Thread-safe via sync.RWMutex.
  • Key types: Hook[T], Handler[T], Event (embeddable base), Resolver (interface requiring Next() error)
  • Dependencies: tools/security (for ID generation only)

apis (HTTP layer)#

  • Package: github.com/pocketbase/pocketbase/apis
  • Responsibility: Route registration and HTTP handlers. NewRouter(app) creates a tools/router instance, attaches global middleware (activity logger, panic recovery, rate limiting, auth token loading, security headers, body limit), then binds all API sub-groups under /api/.
  • Key types: ServeConfig, route group bindings
  • Dependencies: core, tools/router, tools/hook, forms, mails

tools/router#

  • Package: github.com/pocketbase/pocketbase/tools/router
  • Responsibility: A thin wrapper around Go 1.22 net/http stdlib router (using the new pattern matching with {param} and {path...} wildcards). Exposes a middleware-style hook chain for each route via tools/hook.
  • Key types: Router[T], Route[T], Group[T]
  • Dependencies: stdlib net/http, tools/hook

plugins/jsvm#

  • Package: github.com/pocketbase/pocketbase/plugins/jsvm
  • Responsibility: Embeds the Goja JavaScript runtime. Loads scripts from pb_hooks/ and pb_migrations/ directories, pre-warms a pool of Goja runtimes, and registers Go-side hook handlers that invoke JS callbacks for every core lifecycle event (record CRUD, HTTP requests, auth flows, etc.).
  • Key types: Config, pool of goja.Runtime instances
  • Dependencies: core, tools/hook, github.com/dop251/goja

migrations#

  • Package: github.com/pocketbase/pocketbase/migrations
  • Responsibility: Built-in system migrations auto-applied during Bootstrap() via app.RunSystemMigrations(). Uses a sequential registry pattern — each migration is a Go function registered with a timestamp key.
  • Dependencies: core

Data flow#

HTTP request (record create example)#

net.Listener (TCP)
  → http.Server.Serve()
  → tools/router mux
  → global middleware chain (priority-ordered hooks):
      1. panicRecover
      2. activityLogger
      3. rateLimit
      4. loadAuthToken  (parses JWT, sets e.Auth on RequestEvent)
      5. securityHeaders
      6. BodyLimit
  → route handler: apis.recordCreateHandler()
      → fires app.OnRecordCreateRequest hook
          → forms.RecordUpsert.Submit()
              → fires app.OnRecordValidate hook
              → fires app.OnRecordCreate hook
                  → fires app.OnRecordCreateExecute hook (writes to SQLite)
                  → fires app.OnRecordAfterCreateSuccess hook
          → response JSON written to e.Response

Realtime (SSE)#

Client HTTP GET /api/realtime
  → RealtimeConnect handler
  → subscriptions.Broker.Connect() → creates SSE client
  → goroutine holds connection open
  → on record change: app fires OnRecordAfterCreateSuccess / UpdateSuccess / DeleteSuccess
      → subscriptionsBroker fans out messages to matching SSE clients

Initialization / Bootstrap#

Sequence:

  1. pocketbase.New() — creates PocketBase with a cobra.Command and core.BaseApp, eagerly parses CLI flags (--dir, --dev, --encryptionEnv, --queryTimeout), registers the modernc version check hook.

  2. app.Start() — registers serve and superuser Cobra subcommands, then calls app.Execute().

  3. app.Execute() — bootstraps the app (unless --help/--version), starts a goroutine for signal handling (SIGTERM/SIGINT), and runs the Cobra command tree.

  4. app.Bootstrap() fires app.OnBootstrap().Trigger(...). The default handler inside the trigger:

    • Calls ResetBootstrapState() (closes any existing DB connections)
    • Creates pb_data/ directory
    • initDataDB() — opens two SQLite connections (WAL mode, concurrent + nonconcurrent builders) using DefaultDBConnect
    • initAuxDB() — same for the auxiliary DB (pb_data/auxiliary.db for logs)
    • initLogger() — configures structured slog logger
    • RunSystemMigrations() — applies built-in schema migrations
    • ReloadCachedCollections() — loads collection schemas into in-memory cache
    • ReloadSettings() — loads app settings from _params table
  5. apis.Serve(app, config) (called from cmd/serve.go):

    • app.RunAllMigrations() — runs any pending user migrations
    • NewRouter(app) — registers all API routes
    • Fires app.OnServe().Trigger(serveEvent) — plugins attach routes here
    • router.BuildMux() — materializes net/http mux
    • Starts TCP listener and HTTP/HTTPS server

Dependency injection: Manual wiring only. core.App is passed explicitly to every package that needs it (apis, forms, mails, plugins). No DI container. The PocketBase struct embeds core.App directly so callers interact with a single unified interface.

Transaction isolation: app.RunInTransaction(fn) creates a shallow copy of BaseApp with a transaction-aware dbx.Builder, wraps it in TxAppInfo, and passes it to fn. Hook handlers inside the transaction receive this scoped App instance.

Configuration#

SourceWhat it configures
CLI flags (--dir, --dev, --encryptionEnv, --queryTimeout)Data directory, dev mode, settings encryption, query timeout
SQLite _params tableAll app settings (SMTP, S3, OAuth2 providers, tokens, email templates, rate limits, etc.) loaded via app.ReloadSettings()
Environment variable (name from --encryptionEnv)32-char AES key for optional settings encryption at rest
Plugin flags (--hooksDir, --hooksPool, --migrationsDir, etc.)Registered as PersistentFlags on RootCmd by each plugin

No Viper, no config files. Settings are stored directly in SQLite and reloaded on demand. This is an intentional design decision to keep the binary self-contained.

Key design decisions#

  1. core.App as the kernel interface (~150 methods): Every subsystem receives core.App as its dependency. The large interface is acknowledged as non-ISP-compliant (godoc comment notes it is not meant to be implemented by users), but it provides one-stop access to all infrastructure and enables the transaction isolation pattern. The interface-driven design means all tests can pass a core.BaseApp instance without mocking.

  2. Hook chain as the universal extension point: Rather than separate plugin APIs for routes, middleware, model hooks, and background jobs, PocketBase uses one uniform mechanism — hook.Hook[T].Trigger() with e.Next() chaining — everywhere. This means JavaScript plugins in jsvm use the same hook API as compiled Go extensions, making the extension model consistent and predictable.

  3. Dual SQLite connection pools (concurrent + nonconcurrent): BaseApp maintains four dbx.Builder fields — concurrent and nonconcurrent builders for both data and aux DBs. This is specifically tuned for SQLite’s WAL mode: concurrent reads can use the concurrent pool; writes funnel through the nonconcurrent one to avoid WAL conflicts. This is a deliberate trade-off: not scaling to multiple machines, but squeezing maximum single-node throughput from SQLite.

  4. JavaScript extensibility without CGo or subprocess IPC: plugins/jsvm embeds Goja (pure-Go JS runtime) with a pre-warmed pool of goja.Runtime instances. This avoids the cold-start cost of a fresh runtime per request while keeping the binary CGo-free. TypeScript type definitions are generated and committed to plugins/jsvm/internal/types/ so JS developers get autocomplete without a separate SDK.

  5. examples/base/main.go as the canonical production binary: The binary that users download is built from a file named examples/base/main.go. This naming convention signals that the PocketBase struct itself is the library, and the binary is just one way to use it. Library users write the same 20-line main.go pattern, customizing it with their own hooks before calling app.Start(). The “example” is indistinguishable from production — it is the reference implementation.