Plugin and Extension Systems in Go#
Summary#
Across 51 Go projects, plugin and extension systems fall into seven distinct mechanism categories, from in-process init() registries to out-of-process gRPC subprocess protocols. The dominant pattern is compile-time interface composition — over half the corpus extends behavior via Go interfaces wired at startup, with no binary boundary. Only a small cluster of infrastructure tools (Vault, Terraform, Grafana, Nomad) crosses the process boundary. The most architecturally sophisticated systems are Caddy (in-process namespace registry), Traefik (Yaegi interpreter + WASM), and Kubernetes (multi-layered gRPC plugin interfaces). The majority of projects — web frameworks, CLI tools, databases — never need true runtime plugin loading.
Taxonomy#
Approach 1: Subprocess + gRPC (hashicorp/go-plugin)#
The gold standard for trusted-but-isolated third-party code. A plugin binary is launched as a subprocess; communication happens over a loopback mTLS gRPC connection with a magic-cookie handshake.
Projects using it: vault, terraform, grafana, nomad
How it works: The host process calls
go-plugin.Client.Start()which forks the plugin binary, negotiates a protocol version via stdout/stdin, and establishes a gRPC channel. The plugin binary callsgo-plugin.Serve(). Both sides share proto-generated interfaces; the host can’t tell whether a plugin is in-process or out-of-process.- Vault:
logical.Backendinterface (auth methods, secret engines). Single binary can multiplex multiple mount instances since Vault 1.12. Plugin catalog stores SHA256 hashes for verification. - Terraform:
tfplugin5.proto/tfplugin6.protodefineservice Provider(~30 RPCs). Providers are discovered via module registry, filesystem, or dev override. Dual protocol versions coexist; Protocol 6 adds features while Protocol 5 remains for backward compatibility. - Grafana:
pluginv2gRPC protocol (QueryData,CallResource,CheckHealth, etc.). Plugin processes are discovered by scanning filesystem forplugin.jsonmanifests; signature verification blocks unsigned plugins by default. The Grafana Plugin SDK (grafana-plugin-sdk-go) is a separate published module for plugin authors. - Nomad: Task drivers (Docker, QEMU, Java, raw_exec) expose the
DriverPlugininterface via go-plugin. Fingerprinting, task lifecycle, network namespace isolation — all plugin responsibilities.
- Vault:
When it’s appropriate: When plugin code is third-party and untrusted, when plugins should crash-isolate from the host, when plugins need independent upgrade cycles, or when you want language-agnostic extensibility (compile the plugin in any language that produces a gRPC server).
Key tradeoff: Subprocess overhead (~5ms launch, ~1ms IPC round trip) is acceptable for infrastructure operations but prohibitive for request-level hot paths.
Approach 2: In-Process init() Registry#
Plugins register themselves by calling a global registration function from their init() function. The main binary blank-imports all plugin packages. No subprocess, no gRPC; the plugin runs in the host process.
Projects using it: caddy, rclone, prometheus (exporters)
How it works:
- Caddy:
caddy.RegisterModule(m Module)called frominit().Module.CaddyModule()returns aModuleInfowith a dotted namespace ID (e.g.,http.handlers.reverse_proxy) and a constructorfunc() any. The namespace determines which parent config key loads the module and which interface it must implement. Alist-modulescommand enumerates all registered modules. - Rclone:
fs.Register(&fs.RegInfo{Name: "s3", ...})called frominit(). Each backend’sRegInfodeclares its options as[]fs.Option, which auto-surface as--s3-*flags. All backends are linked in viabackend/all/all.goblank imports. - Prometheus:
promauto.NewGaugeVec(...)andprometheus.MustRegister(...)frominit()register metrics collectors into a global default registry. Not a “plugin” in the functional sense, but uses the sameinit()-based self-registration pattern.
- Caddy:
When it’s appropriate: When you own all the plugin code (or accept that plugins require recompilation), when you want zero IPC overhead, and when plugin authors are Go developers who can ship a custom build. Rclone explicitly acknowledges this: all backends are compile-time only.
Key tradeoff: Extensibility requires a recompile. Caddy addresses this with
xcaddy, a build-time tool that downloads and compiles plugin packages into a custom Caddy binary. The runtime module registry becomes a compile-time composition mechanism.
Approach 3: Interpreted Runtime (Source Code at Runtime)#
Instead of compiled binaries, plugins are source code (Go or JS) loaded and executed at runtime by an embedded interpreter.
Projects using it: traefik (Yaegi + WASM), pocketbase (goja/JavaScript)
How it works:
- Traefik / Yaegi:
github.com/traefik/yaegiis a Go interpreter. Plugins are.gosource files; Traefik parses and interprets them at startup. No compilation step for the user. The plugin manifest (.traefik.yml) declares module path and entry point. Remote plugins are fetched fromplugins.traefik.ioas source archives. WASM plugins viawazerooffer language-agnostic alternatives with opt-in syscall/filesystem access. - PocketBase / goja:
pb_hooks/*.pb.jsor*.pb.tsfiles are loaded into a pool of pre-warmedgoja.Runtimeinstances. The JS runtime exposes the full Go hook API —onRecordCreateRequest(fn),routerAdd(...), etc. TypeScript type definitions are pre-generated for IDE autocomplete. This makes PocketBase extensible for non-Go developers without requiring a build toolchain.
- Traefik / Yaegi:
When it’s appropriate: When you want hot-reload without recompilation, when you target non-Go developers, or when you want to distribute plugins as source (auditable, version-controlled). Yaegi specifically targets the “I want to write a Go plugin but can’t require users to compile Caddy” use case — the Go developer experience without the build step.
Key tradeoff: Interpreter performance (Yaegi: ~5–10× slower than compiled Go; goja: ~10–20× slower than V8). Interpreter security surface (Yaegi runs in the same process; WASM adds isolation). Yaegi’s Go compatibility is not 100% — some reflection-heavy patterns don’t work.
Approach 4: Hook/Lifecycle Callback Pipeline#
Extension points are lifecycle events. Code registers callbacks that fire before, during, or after specific operations.
Projects using it: pocketbase (primary extension), gorm (callbacks), kubernetes (admission webhooks — out-of-process variant)
How it works:
- PocketBase:
tools/hook.Hook[T Resolver]— a generic type-parameterized middleware chain. Hooks are registered withapp.OnRecordCreate().BindFunc(...)beforeapp.Start(). EachHook[T]is a priority-ordered list ofHandler[T]instances; callinge.Next()advances the chain. The same hook API works for Go library users and for JSVM-based JS hooks. - GORM:
db.Callback().Create().Before("gorm:create").Register("myapp:audit", fn)— a named pipeline per operation type (Create, Query, Update, Delete, Row, Raw). Ordering is declared by name dependency (Before/After), not by integer priority. Model-levelBeforeCreate/AfterCreatestruct methods are auto-discovered via schema reflection and registered as callbacks automatically. - Kubernetes (webhooks):
MutatingWebhookConfiguration/ValidatingWebhookConfigurationregister HTTPS URLs. The API server calls these URLs for matching resource operations. This is the out-of-process variant of the hook pattern — handlers are external HTTP services rather than in-process callbacks. Runs after auth, before persistence.
- PocketBase:
When it’s appropriate: When you want to add cross-cutting concerns (audit, validation, enrichment) to an existing data flow without modifying core logic. GORM’s callback system is the cleanest example in the corpus — it gives plugin authors a stable ordered pipeline with named insertion points.
Key tradeoff: Ordering complexity grows with number of hooks. PocketBase’s priority integer approach is simpler to reason about than GORM’s name-dependency DAG, but both work. The Kubernetes webhook variant adds network latency (typically 1–5ms per admission call) but achieves complete language and deployment isolation.
Approach 5: Language-Agnostic gRPC Protocol Sockets#
Components expose a gRPC server on a Unix domain socket or TCP address. The host discovers and connects to them; no process management. This is “pluggable components” as a deployment pattern, not a code-loading pattern.
Projects using it: dapr, kubernetes (CRI, Device Plugin, CSI, DRA, KMS)
How it works:
- Dapr: Pluggable components implement proto-defined gRPC services (
StateStore,PubSub,InputBinding,OutputBinding,SecretStore) and listen on a Unix domain socket (in Kubernetes) or TCP address. Dapr discovers them by reading component YAML files at startup. A Python Redis state store, a Rust S3 binding, or a Java Kafka pub/sub are all equally valid. - Kubernetes: CRI (Container Runtime Interface), Device Plugin API, CSI (Container Storage Interface), DRA (Dynamic Resource Allocation), KMS (Key Management Service) all define gRPC service contracts. Implementations (containerd, Nvidia GPU plugin, aws-ebs-csi-driver, etc.) listen on known Unix socket paths; kubelet connects to them at node startup. Plugin registration happens via a separate
Registrationservice that components call to announce themselves.
- Dapr: Pluggable components implement proto-defined gRPC services (
When it’s appropriate: When you want maximum language diversity in plugins, when plugin lifecycle is managed externally (Kubernetes manages component Pod lifecycle), or when you want zero coupling between host and plugin build toolchains. This is “microservices as plugins.”
Key tradeoff: Operational complexity — each component is a separately deployed process. Discovery requires explicit configuration (Dapr component YAML) or well-known socket paths (Kubernetes). Network/socket overhead exists even for local calls.
Approach 6: Interface Injection and Middleware Chains#
Extension points are Go interfaces wired at construction time. Middleware chains allow inserting func(next Handler) Handler wrappers. No subprocess, no registry, no dynamic loading.
Projects using it: echo, gin, fiber, beego, buffalo, traefik (providers + middleware), caddy (per-namespace interface contracts), chi (used by drone, pocketbase, dapr)
How it works:
- Echo: Five interfaces are swappable at
NewWithConfig(echo.Config{Router: myRouter, Binder: myBinder, ...}). The middleware chain is built bye.Use(m1, m2)+ per-routee.GET("/path", handler, authMiddleware). - Gin:
binding.Validator(package-level var) can be replaced for a different validation engine.HandlerFuncvariadic chain — handlers and middleware are the same type, composable viaUse()and inline at route registration. - Traefik providers: The
Providerinterface (Init() error,Provide(chan<- dynamic.Message, *safe.Pool) error) is how all dynamic config sources (Docker, Kubernetes, Consul, File, HTTP, custom plugins) hook in. The provider aggregator accepts any implementation. - GORM: The
Plugininterface (Name() string,Initialize(*DB) error) is registered viadb.Use(myPlugin). Plugins initialize themselves by registering callbacks — the plugin pattern is layered on top of the callback pattern.
- Echo: Five interfaces are swappable at
When it’s appropriate: For frameworks and libraries where all consumers are Go developers, where compile-time type safety matters more than runtime extensibility, and where the “plugin” is really a strategy or policy that the consumer controls. HTTP frameworks live here permanently.
Key tradeoff: No hot-reload, no independent deployment, requires recompilation. But also: zero overhead, full type safety, no serialization boundary. For frameworks, this is the right answer.
Approach 7: Binary PATH Discovery (No Protocol)#
Plugin executables are discovered by convention on $PATH or in a designated directory. The host invokes them as subprocesses; communication is stdin/stdout or via environment variables. No gRPC, no proto.
Projects using it: kubectl (kubectl-* plugins), helm (helm-* plugins), hugo (external markup converters: asciidoctor, pandoc, rst2html)
How it works:
- kubectl:
kubectl plugin listscans$PATHfor executables namedkubectl-*. They are invoked as subprocesses with the same arguments the user typed afterkubectl plugin. No registration, no version negotiation, no protocol — pure convention. - Helm:
$HELM_PLUGINSdirectory contains subdirectories with aplugin.yamlmanifest.helm plugin install <url>downloads and installs them. Plugins receive command arguments and theHELM_PLUGIN_DIRenv var. - Hugo: No Go plugin interface for markup — instead, Hugo invokes external binaries (
asciidoctor,pandoc,rst2html) as subprocesses when the relevant converter is configured. stdin/stdout protocol. The Go side is justos/exec.
- kubectl:
When it’s appropriate: When CLI extensibility is desired, when plugins are shell scripts or binaries in any language, and when the use case is “run arbitrary code as a subcommand.” The simplest possible plugin model.
Key tradeoff: No type safety, no version negotiation, no error protocol beyond exit codes. Works well for CLI commands; terrible for data-intensive or latency-sensitive operations.
Projects Without a Plugin System#
The majority of the corpus has no plugin/extension system beyond Go interface composition:
| Project | Extension model | Notes |
|---|---|---|
| etcd | None | Watches + leases are the extension model; consumers implement their own logic |
| prometheus | init() metric registration | Extensibility is at the library consumer level, not plugin loading |
| minio | Compile-time backends | S3-compatible; extensions are adapters, not plugins |
| cockroach | None | All functionality is built in; no external plugin protocol |
| consul | Compile-time backends | Service discovery strategies are compiled in |
| k3s | Kubernetes plugin contracts (CRI/CNI/CSI) | K3s uses containerd + flannel/calico — no new plugin protocol |
| istio | Envoy Wasm extensions | The Go layer has no plugin system; Envoy filters handle extensibility |
| argo-cd | CRD + webhook | Extensibility through Kubernetes primitives |
| tekton-pipeline | CRD (Tasks, Steps) | Pipeline steps are container images, not Go plugins |
| syncthing | None | Compile-time device discovery |
| restic | Compile-time backends | Repository backends are compiled in |
| nats-server | None | Configuration-driven; leaf nodes and clustering are built-in |
| temporal | Workflow code IS the plugin | Workers execute user-defined Go code; no plugin loading per se |
| fzf | None | Pure algorithm; no extension points |
| gh | PATH-based extensions (gh extension) | Similar to kubectl-* pattern |
| gitea / gogs | Webhooks + Git hooks | Extension via HTTP callbacks, not Go plugins |
| buildkite-agent | Plugin steps via Docker | CI steps are containers, not Go plugins |
| air | None | File-watcher utility |
| pop / delve | None | CLI tools |
| frp / headscale / tailscale / wireguard-go | None | Network tools; compile-time feature selection |
| cobra / viper / sqlc | None | Libraries with no plugin model |
| crush | None | TUI app |
| fyne | None (renderer backend is compile-time) | GUI framework; backends (OpenGL, WASM) are build-tag selected |
Comparison Dimensions#
Extension Point Naming and Identity#
| Project | Identity scheme | Example |
|---|---|---|
| Caddy | Dotted namespace string | "http.handlers.reverse_proxy" |
| Rclone | Short string name | "s3", "sftp" |
| Vault | Type + name | logical.TypeLogical + "kv" |
| Terraform | Provider address | "registry.terraform.io/hashicorp/aws" |
| Grafana | Plugin ID string | "grafana-piechart-panel" |
| Dapr | YAML component type | state.redis/v1 |
| GORM | String name | "myapp:before-create" |
| PocketBase | Go type or hook name | OnRecordCreate |
| Kubernetes | GVK (Group/Version/Kind) | apps/v1/Deployment |
| Traefik | YAML key + middleware name | type: basicAuth, name in config |
The Caddy dotted-namespace scheme is the most elegant: the namespace encodes both where in the config JSON tree the module appears (http.handlers.*) and which interface it must implement. New namespaces can be added by any module without modifying Caddy core — the parent module just looks for children in its namespace using ctx.LoadModule().
Registration Timing#
| Pattern | Projects | Notes |
|---|---|---|
init() (process startup, before main) | caddy, rclone, prometheus, gin, echo, fiber | Order-independent; global registry |
Constructor injection (New() or config struct) | echo, gin, fyne, beego | Explicit, testable, no global state |
Boot-time Use()/Register() call | gorm, pocketbase, dapr, vault | After init, before start; explicit ordering |
| Runtime (config reload / hot add) | caddy (reload), vault (mount at runtime), grafana (plugin install) | Varies; caddy reloads config without process restart |
| Build-time (blank import) | rclone (backends), caddy (built-in modules) | Compile-time only |
Security Model Comparison#
| Project | Isolation | Trust model |
|---|---|---|
| vault | Subprocess + mTLS | SHA256 hash verification of plugin binary |
| terraform | Subprocess + mTLS | Registry signature verification |
| grafana | Subprocess + mTLS | Signature verification; unsigned plugins blocked by default |
| traefik (WASM) | wazero sandbox | Opt-in env/filesystem access |
| traefik (Yaegi) | Same process | No sandbox; Yaegi code runs with host privileges |
| dapr | Unix socket | Component process isolated; socket ACLs |
| kubernetes CRI/Device | Unix socket + registration | Node-level; kubelet trusts registered components |
| caddy | Same process | Trust the module author (recompile model) |
| pocketbase (JSVM) | Same process (goja) | JS cannot do syscalls; Go hook API is the boundary |
| kubectl plugins | Subprocess | No verification; PATH-based trust |
The go-plugin + mTLS + hash-verification stack (Vault/Terraform/Grafana) provides the strongest security guarantees in the corpus for third-party untrusted plugins. The Yaegi approach is the weakest — interpreted Go code runs with full host privileges, making it appropriate only for trusted or self-authored plugins.
Common Patterns#
The
init()self-registration idiom is nearly universal for in-process extensions. Projects as different as Caddy (modules), Rclone (backends), and Prometheus (metrics) all use it. The mechanism is idiomatic Go: import the package, the package registers itself.Interface as contract. Every plugin system ultimately defines an interface (or proto service) that plugins must implement. The richness of the interface correlates with the complexity of the domain: GORM’s
Plugininterface has 2 methods; Vault’slogical.Backendhas 12+; Terraform’sproviders.Interfacehas 30+.Registry + factory pattern. Most systems store a
map[string]func() Interface— a named factory registry. The key identifies the plugin type; the value is a constructor called at instantiation time (to create per-mount/per-route instances). This pattern appears in Caddy (ModuleInfo.New), Vault (CoreConfig.LogicalBackends), Rclone (fs.Registry), and GORM (Config.Plugins).Namespace scoping. Caddy’s dotted namespace (e.g.,
http.handlers.*) is the most sophisticated example of scoping extension points. Dapr’sstate.redis/v1component type string serves a similar function. Kubernetes GVK is the most elaborate namespace in the corpus.Middleware chain as the simplest plugin system. Every HTTP framework provides
func(next Handler) Handlerchaining. This is the most widely used extension mechanism in the corpus — virtually every project serving HTTP uses it. It’s the lowest-complexity plugin system that still enables meaningful cross-cutting extensibility.Proto as the stable ABI. Projects crossing process boundaries (Vault, Terraform, Grafana, Dapr, Kubernetes) all use protobuf as the serialization format. Proto provides language neutrality and backward-compatible schema evolution. The absence of proto correlates with same-process extension (Caddy, Rclone, Echo).
Divergent Choices#
Where they differ most#
Process boundary vs. same-process: The sharpest divide is between infrastructure tools that cross the process boundary (Vault, Terraform, Grafana, Nomad) and everything else. The infrastructure tools have explicit requirements: third-party trust boundaries, crash isolation, independent upgrade cycles, multi-language plugin authors. Web frameworks have none of these requirements.
Source code vs. binary: Traefik’s Yaegi approach (interpret Go source at runtime) is unique in the corpus. No other project interprets the host language at runtime for plugins. This choice stems from Traefik’s desire to offer a plugin marketplace (plugins.traefik.io) with user-authored middleware, but without requiring users to compile a custom binary. PocketBase’s goja (JS) choice is similar in motivation but targets a different developer audience.
Namespace granularity: Caddy’s namespace system is the most granular — it partitions Caddy’s config JSON into subtrees, each with its own interface requirement. Vault uses a coarser type taxonomy (auth method, secret engine, audit backend). Rclone treats all backends as equivalent. The finer the namespace, the more targeted the interface requirements, but also the more surface area to understand.
Runtime vs. compile-time: Caddy’s xcaddy tool and Grafana’s plugin installer (grafana cli plugins install) represent two philosophies for “deploy a new plugin at runtime”: xcaddy recompiles the entire binary; Grafana downloads and starts a new subprocess. The binary recompile model trades deploy time for security and performance; the subprocess model trades isolation and operational overhead for convenience.
Trends#
Size → complexity of plugin system. XL projects (Kubernetes, Grafana, Vault, Terraform) all have rich out-of-process or multi-layered plugin systems. S/M projects (fzf, air, cobra, viper) have none. The correlation is strong: 0/16 small projects have a true plugin system; 8/8 large infrastructure tools do.
Domain → mechanism choice. Infrastructure and platform tools gravitate toward subprocess + gRPC (security, isolation, multi-language). Developer tools and frameworks gravitate toward in-process interface composition (performance, simplicity). Content and CLI tools may have no plugin system at all.
Age and the init() pattern. Older Go projects (rclone, prometheus, caddy) use
init()self-registration heavily. Newer projects (pocketbase, dapr) prefer explicitapp.Use()/ config-file component declaration, avoiding the implicit global state of init().gRPC as the universal IPC language. All projects that cross a process boundary for plugins use gRPC. None use REST, none use JSON over stdin/stdout (except kubectl/helm’s trivial pattern). gRPC’s generated types, streaming support, and bidirectional calls make it the clear winner for plugin IPC.
Typed registries over stringly-typed configs. The best plugin systems have a typed registry (
map[string]func() Interface) rather than just a string key pointing to a config blob. Caddy’sModuleInfo.ID+ModuleInfo.Newis typed; Dapr’s component YAMLtype: state.redis/v1is stringly-typed until loaded.
Best Practices#
Synthesized from the projects with the most effective plugin systems:
Define a narrow interface. GORM’s
Plugin(2 methods) and Caddy’sModule(1 method) are minimal entry points. Vault’slogical.Backend(12 methods) is wider but justified by the domain. Resist adding methods to the plugin interface that aren’t strictly required — each method is a burden on all plugin authors.Separate instantiation from configuration. Caddy’s
CaddyModule().Newreturns a fresh empty struct; configuration is applied viaProvision(Context)called separately. This lets the registry create instances before config is available, and supports config reload by creating new instances rather than mutating live ones.Name your callbacks. GORM requires every callback to have a name (
"myapp:before-create"). Names enableRemove(name)andReplace(name, fn)— plugins can override each other. Anonymous callbacks cannot be targeted.Verify plugins before running them. Vault and Terraform store SHA256 hashes of plugin binaries in their catalogs. Grafana requires cryptographic signatures for plugins in the official catalog. Unsigned plugins should at minimum emit a warning. The kubectl/helm model (any binary on PATH) is appropriate only when the user explicitly opted in.
Give plugins access to system context, not raw pointers. Caddy’s
caddy.Context(passed toProvision()) providesctx.App(name),ctx.LoadModule(),ctx.Logger()— scoped access to the host. Vault’sSystemView(via gRPC callback) provides similar scoped access to secret system state. This keeps plugin coupling at the interface level, not the implementation level.Support lifecycle hooks. Caddy’s
Provisioner,Validator,CleanerUpperinterfaces let modules opt into lifecycle phases they need. GORM’sBeforeCreate/AfterCreatemethods on model structs do the same. Requiring all plugins to implement all lifecycle methods creates unnecessary burden; optional lifecycle interfaces are the right pattern.Use dotted namespaces for complex systems. Caddy’s namespace scheme (
http.handlers.*,tls.certificates.*,admin.api.*) maps directly to the config JSON tree. Adding a new namespace requires no core change. This is scalable in a way that a flat type-string registry isn’t.
Anti-Patterns#
Global mutable init() registry with no ordering guarantees. The
init()pattern is safe for registration (just appends to a map) but dangerous if plugins depend on each other’s initialization order. Caddy solves this via lazyProvision()calls; rclone’s backends are stateless so order doesn’t matter. A project that needs ordered init should use explicitUse()calls instead.Plugin interfaces with too many methods. Terraform’s
providers.Interfacehas 30+ methods. This creates a high burden on both implementations and test doubles. Grafana’sPluginContextProviderinterface has grown over time to include optional capabilities that should have been separate optional interfaces.Interpreted plugins in the same process without sandboxing. Traefik’s Yaegi plugins run with full host process privileges. A malicious Yaegi plugin can read environment variables, open network connections, and call arbitrary Go code. The Yaegi approach is sound for trusted/self-authored plugins but should not be used for a public marketplace of unknown third-party code.
Stringly-typed plugin activation. Consul’s and Nomad’s plugin types use string keys in config files without schema validation. A typo in
"stype": "seals"silently falls back to default. The best systems (Vault, Terraform) validate plugin types at configuration parse time.No version negotiation. kubectl’s PATH-based plugin model has zero version negotiation. The host and plugin must be compatible by convention. Traefik’s plugin manifest declares a
version; Terraform’s plugin protocol has explicitGetMetadatacapability exchange. For anything more complex than CLI commands, version negotiation is required.
Exemplars#
Caddy — best in-process plugin system in the corpus. The namespace-keyed module registry, lifecycle interfaces (Provisioner, Validator, CleanerUpper), scoped context injection, and Caddyfile directive registration all work together. The xcaddy build tool solves “how do users add plugins without maintaining a fork” elegantly. If you’re building an extensible server in Go and don’t need subprocess isolation, copy this design.
Vault — best out-of-process plugin system in the corpus. The logical.Backend interface is narrow enough to implement; the go-plugin + mTLS + SHA256 hash stack provides genuine security isolation; the in-process/out-of-process duality (logical.Backend is the same Go interface regardless of how it’s served) keeps the core architecture clean. Vault’s plugin catalog (operator-managed registry with binary fingerprints) is the right operational model for production infrastructure.
PocketBase — best hook-based + scripted extension system. The generic tools/hook.Hook[T] is reusable across all lifecycle events; the JSVM layer exposes the same hook API to non-Go developers without requiring a build step. The OnServe hook + e.Router access pattern lets Go library users extend PocketBase’s HTTP API with the full middleware stack automatically applied — there’s no “plugin API” separate from the “normal API.”
Note on fyne and crush#
Neither fyne nor crush has a plugin system in the traditional sense.
Fyne — extensibility is at the renderer/backend level, selected at compile time via build tags (OpenGL, WASM, software). Theme customization via fyne.Theme interface (Caddy-style interface injection) is the primary extension point. No runtime plugin loading.
Crush — a TUI app with no extension points. See analysis/results/P51-crush--ai-development-profile.md for context on its development approach. Its interface-driven design (bubbletea conventions) is inherent to the TUI domain rather than a plugin architecture signal.