- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I know of shipped a release where one line in the
notes mattered more than the rest: "now loads auth checks
as Go plugins." They had reached for the stdlib plugin
package because the docs are right there, the API is one
function call, and the demo in every blog post makes it
look easy.
The rollout did not go well. A plugin built on one machine
refused to load on hosts running a slightly different Go
patch version, and the error was a hash mismatch with no
graceful fallback. They ended up deleting the plugin code
and shipping a hot-reload-disabled build to recover.
Go's stdlib plugin package is the wrong default for almost
every team that reaches for it. It only works on Linux,
macOS, and FreeBSD. The plugin and the host must be built
with the exact same Go toolchain, module versions, and build
tags. Windows is not supported at all. There is no
sandboxing, and a panic in a plugin takes down the host. The
official docs say the package is "currently known to have a
number of issues". That has been
the warning for years.
You almost never want it. What you want is one of the
patterns below — pick the one that matches your isolation
and language story.
A common interface, three implementations
Every example below implements the same Hook contract. The
host has one job: ask a hook whether a request is authorised.
// hook/hook.go - shared by host and every plugin pattern.
package hook
import "context"
type AuthRequest struct {
UserID string
Resource string
Action string
}
type AuthDecision struct {
Allow bool
Reason string
}
type Hook interface {
CheckAuth(
ctx context.Context, r AuthRequest,
) (AuthDecision, error)
}
That interface is the contract. Every pattern below honours
it. What changes is where the implementation lives and
how the host talks to it.
Pattern 1: compile-time registration
The simplest plugin system is no plugin system. You define
the interface, every implementation lives in its own
package, and a tiny registry collects them at process start
via init(). No dynamic loading, no version skew, no
runtime surprise.
// hook/registry.go
package hook
import "fmt"
var registry = map[string]Hook{}
func Register(name string, h Hook) {
if _, exists := registry[name]; exists {
panic("hook already registered: " + name)
}
registry[name] = h
}
func Get(name string) (Hook, error) {
h, ok := registry[name]
if !ok {
return nil, fmt.Errorf(
"hook %q not registered", name)
}
return h, nil
}
A hook implementation registers itself.
// hook/checks/admin/admin.go
package admin
import (
"context"
"myapp/hook"
)
type adminOnly struct{}
func (adminOnly) CheckAuth(
_ context.Context, r hook.AuthRequest,
) (hook.AuthDecision, error) {
if r.UserID == "admin" {
return hook.AuthDecision{Allow: true}, nil
}
return hook.AuthDecision{
Allow: false,
Reason: "admin-only resource",
}, nil
}
func init() {
hook.Register("admin-only", adminOnly{})
}
The host imports the package for its side effect:
import _ "myapp/hook/checks/admin"
That underscore import wires the hook in. To enable a hook
in a build, you add the import. To disable it, you remove
the import. To ship a different feature flag set, you
maintain build tags or separate main packages that pull in
different subsets.
What this gives you: zero call overhead, full type safety,
no version drift, easy debugging. What it costs you: every
hook ships in the same binary, every change requires a
redeploy, and untrusted code is a non-starter because there
is no isolation.
My read is this pattern covers most real plugin needs in
Go. Most teams that reached for plugin actually wanted
this one.
Pattern 2: subprocesses over gRPC
When you need real isolation between the host and the
extension, run it in another process and talk to it over
RPC. Maybe a panic in the extension must not kill the host.
Or the extension is third-party code you don't trust to
share an address space with. Or it has a lifecycle of its
own and needs to start, stop, and restart independently.
The fix is the same: get it out of your address space.
This is the model HashiCorp built go-plugin around, and it
is the system that keeps Terraform, Nomad, Vault, and
Waypoint extensible across hundreds of provider binaries
(repo). The
library handles handshake, version negotiation, transport,
and graceful shutdown. The transport is gRPC, which means
plugins can be written in any language with a gRPC stack.
A minimal hook plugin looks like this. The proto definition
maps the Hook interface line by line:
syntax = "proto3";
package hookpb;
service Hook {
rpc CheckAuth (AuthRequest) returns (AuthDecision);
}
message AuthRequest {
string user_id = 1;
string resource = 2;
string action = 3;
}
message AuthDecision {
bool allow = 1;
string reason = 2;
}
The proto compiles into the Go module path myapp/hookpb
via your protoc invocation. The plugin process then
registers a gRPC server that implements the service:
// plugin-admin/main.go
package main
import (
"context"
"github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
pb "myapp/hookpb"
)
type adminServer struct {
pb.UnimplementedHookServer
}
func (s *adminServer) CheckAuth(
_ context.Context, r *pb.AuthRequest,
) (*pb.AuthDecision, error) {
return &pb.AuthDecision{
Allow: r.UserId == "admin",
Reason: "admin-only resource",
}, nil
}
type hookGRPC struct {
plugin.NetRPCUnsupportedPlugin
}
func (hookGRPC) GRPCServer(
_ *plugin.GRPCBroker, s *grpc.Server,
) error {
pb.RegisterHookServer(s, &adminServer{})
return nil
}
func main() {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "HOOK_PLUGIN",
MagicCookieValue: "v1",
},
Plugins: map[string]plugin.Plugin{
"hook": hookGRPC{},
},
GRPCServer: plugin.DefaultGRPCServer,
})
}
The host launches the plugin binary, the library handshakes
on stdin/stdout, then handshakes off to a gRPC connection on
a Unix socket or local TCP port. From that point the host
calls CheckAuth like any other gRPC method.
The win is process isolation: a panicking plugin takes down
only itself, and the host just sees a dropped connection it
can recover from. You also get language independence (the
plugin can be written in any language with a gRPC stack),
independent deployment (drop a new binary in plugins/),
and a debuggability story where you can attach a debugger to
the plugin process directly. The cost is an IPC hop and a
serialisation round trip on every call, plus the operational
tax of supervising extra processes. Expect latency in the
hundreds of microseconds to low milliseconds on the same
host — fine for control-plane work, too slow for hot loops.
Measure for your workload.
One trap to avoid: do not use go-plugin's net/rpc mode
for new systems. It is gob-encoded and Go-only, which kills
the cross-language story that's half the reason to reach for
this pattern in the first place. gRPC mode is what HashiCorp
recommends today.
Pattern 3: WebAssembly via wazero
The third pattern is what you reach for when you want
sandboxing on top of polyglot support. The plugin is a
WebAssembly module. The host runs it in an in-process
runtime. The plugin cannot touch the file system, the
network, or the host's memory unless the host explicitly
hands it a capability.
wazero is the runtime to use. It is a
pure-Go WebAssembly runtime with zero dependencies and no
CGO, which means cross-compilation still works and your
deployment story stays simple. It is WASI-Preview-1
compatible and Wasm Core 1.0/2.0 compliant.
The host instantiates the runtime once, compiles the plugin
module, and invokes exported functions:
// host/wasm.go
package host
import (
"context"
"encoding/json"
"os"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
wasi "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
"myapp/hook"
)
type WasmHook struct {
runtime wazero.Runtime
module api.Module
checkFn api.Function
memory api.Memory
mallocFn api.Function
}
func LoadWasmHook(
ctx context.Context, path string,
) (*WasmHook, error) {
rt := wazero.NewRuntime(ctx)
wasi.MustInstantiate(ctx, rt)
bin, err := os.ReadFile(path)
if err != nil {
return nil, err
}
mod, err := rt.Instantiate(ctx, bin)
if err != nil {
return nil, err
}
return &WasmHook{
runtime: rt,
module: mod,
checkFn: mod.ExportedFunction("check_auth"),
memory: mod.Memory(),
mallocFn: mod.ExportedFunction("malloc"),
}, nil
}
// writeBytes calls the guest's malloc, copies `in` into
// guest memory, and returns the guest pointer. readResult
// reads a length-prefixed byte slice back out at `res[0]`.
// Both are short helpers (~10 lines each) — omitted here
// for brevity; see the wazero examples for the canonical
// shape.
func (h *WasmHook) CheckAuth(
ctx context.Context, r hook.AuthRequest,
) (hook.AuthDecision, error) {
in, _ := json.Marshal(r)
ptr, err := writeBytes(ctx, h, in)
if err != nil {
return hook.AuthDecision{}, err
}
res, err := h.checkFn.Call(
ctx, ptr, uint64(len(in)))
if err != nil {
return hook.AuthDecision{}, err
}
out := readResult(h.memory, res[0])
var dec hook.AuthDecision
if err := json.Unmarshal(out, &dec); err != nil {
return hook.AuthDecision{}, err
}
return dec, nil
}
The plugin side can be written in any language that
compiles to Wasm. Rust, TinyGo, AssemblyScript, Zig all
work. A TinyGo version of the same admin-only hook is under
30 lines and compiles to a .wasm file under 50 KB.
You get hot-reload almost for free: drop a new .wasm in,
re-instantiate, no host restart. You get sandboxing — the
plugin sees nothing the host does not explicitly pass it.
You get language independence and a security model you can
actually reason about. The price is serialisation across
the host/wasm boundary, a slower cold start than native
code, and a debugging story that is still maturing.
CPU-bound work inside Wasm runs measurably slower than
native Go; the gap depends heavily on workload and on
which Wasm runtime you choose. Benchmark your hot path
before committing.
How to pick
The decision tree is short.
- All hooks are first-party, you control every binary, and you redeploy when hooks change. That's compile-time registration. Boring, right answer.
- Hooks are third-party, written in any language, or need
process isolation because crashes must not propagate.
That's
go-pluginover gRPC. - Hooks come from untrusted sources (customers, marketplace, user-uploaded scripts) and you need real sandboxing on top of polyglot support. That's wazero.
The stdlib plugin package fits none of those well. The
restrictions on toolchain match, OS support, and lack of
sandboxing rule it out for production work. Treat it as a
lab toy. Don't ship it.
If this was useful
The longer version of this argument, including how the same
boundary question shows up in repository design, where the
application service belongs, and how to keep transports
swappable without polluting the core, is most of
Hexagonal Architecture in Go. The Complete Guide to Go
Programming covers the language-level pieces (interfaces,
init order, package boundaries) the patterns above lean on.

Top comments (0)