How to Implement Plugin Architecture with Interfaces in Go
You built a CLI tool that analyzes log files. It works great for your team. Then a user asks for a custom filter that checks for specific regex patterns your tool doesn't support. Another user wants to export results to a proprietary format. You could hardcode every possibility, but that bloats the binary and slows down releases. You want users to write their own extensions without touching your source code. That is a plugin architecture.
Go handles plugins differently than languages like Java or Python. There is no classpath scanning. There is no dynamic import of source files. Go uses compiled shared libraries. The host application loads a binary file at runtime, finds exported symbols, and calls them. This approach is fast and type-safe, but it requires discipline. The plugin must be compiled separately, and it must speak the exact language the host expects.
The contract and the loader
Think of a plugin like a USB device. Your computer has a standard port. The device just needs to plug in and respond to the standard signals. The computer doesn't care if the device is a mouse or a keyboard, as long as it follows the protocol. In Go, the interface is the port. The plugin package is the mechanism that loads the compiled .so file and hands you the symbols.
The host defines an interface. The plugin implements that interface. The host loads the plugin, looks up a symbol, and asserts that the symbol satisfies the interface. If the types match, the host can call methods on the plugin. If they don't match, the program panics.
Here is the minimal host. It defines the contract and loads a plugin file.
package main
import (
"fmt"
"plugin"
)
// Plugin defines the contract. Any loaded code must satisfy this interface.
type Plugin interface {
Name() string
Run() string
}
func main() {
// Open the compiled shared object. Path must be relative or absolute.
p, err := plugin.Open("myplugin.so")
if err != nil {
panic(err)
}
// Lookup finds the exported symbol. The name must match exactly.
sym, err := p.Lookup("NewPlugin")
if err != nil {
panic(err)
}
// Type assert to the interface. The plugin must return something compatible.
impl, ok := sym.(Plugin)
if !ok {
panic("symbol does not implement Plugin interface")
}
fmt.Println(impl.Name())
fmt.Println(impl.Run())
}
The host expects a symbol named NewPlugin. It does not care about the internal structure of the plugin. It only cares that the symbol returns a value that implements Plugin. This is the "accept interfaces" part of the Go style mantra. The host accepts an interface, and the plugin returns a struct that satisfies it.
Here is the plugin. It must be in the main package. The plugin package only loads symbols from main.
package main
// NewPlugin returns a struct that implements the Plugin interface.
// The host expects a symbol named "NewPlugin".
func NewPlugin() Plugin {
return &GreetingPlugin{}
}
// GreetingPlugin holds state for this specific extension.
type GreetingPlugin struct{}
// Receiver name is short and matches the type initial.
func (g *GreetingPlugin) Name() string { return "Greeting" }
func (g *GreetingPlugin) Run() string { return "Hello from plugin" }
The plugin exports NewPlugin. The host looks up NewPlugin. The type assertion checks if the returned value implements Plugin. If the plugin exports a function with the wrong signature, the assertion fails. The receiver name g follows the convention of one or two letters matching the type. You see (b *Buffer) in the standard library, not (this *Buffer).
Build the plugin with the special build mode.
go build -buildmode=plugin -o myplugin.so plugin.go
Run the host. It loads the .so file and prints the output. The plugin runs in the same process. There is no subprocess overhead. The code executes as fast as native code.
What happens at runtime
When you call plugin.Open, the Go runtime loads the shared object into memory. It resolves symbols and initializes the package. The runtime caches opened plugins. If you call plugin.Open with the same file path twice, you get the same *plugin.Plugin instance. This matters for state. If your plugin registers a global variable, it only runs once. Subsequent opens reuse the existing registration.
Lookup searches the symbol table of the loaded plugin. It returns an interface{}. You must type assert to the concrete type or interface you expect. The compiler cannot check this at compile time. The plugin is a separate binary. The host and plugin are compiled independently. If the plugin changes the signature of NewPlugin, the host will panic at runtime. You get a panic like interface conversion: interface is not Plugin. There is no compile-time safety across the plugin boundary.
The plugin runs in the same address space as the host. This means they share memory. If the plugin panics, the host dies. There is no sandbox. If the plugin allocates memory and never frees it, the host leaks memory. Trust the plugin code, or isolate it behind a process boundary.
A realistic loader
Real applications need error handling, logging, and often load multiple plugins. A robust loader wraps the plugin calls and returns errors. It also handles the case where a plugin file exists but exports the wrong symbols.
Here is a loader function that handles errors and wraps them with context.
package main
import (
"fmt"
"plugin"
)
// LoadPlugin opens a plugin file and returns the interface.
// It handles lookup and type assertion in one place.
func LoadPlugin(path string) (Plugin, error) {
p, err := plugin.Open(path)
if err != nil {
// Wrap the error to preserve the original cause.
return nil, fmt.Errorf("open plugin %s: %w", path, err)
}
sym, err := p.Lookup("NewPlugin")
if err != nil {
return nil, fmt.Errorf("lookup symbol: %w", err)
}
// Assert to the interface. This fails if the plugin exports the wrong type.
impl, ok := sym.(Plugin)
if !ok {
return nil, fmt.Errorf("symbol is not a Plugin")
}
return impl, nil
}
The function returns an error instead of panicking. The caller can decide how to handle failures. The error wrapping uses %w to chain errors. This allows the caller to use errors.Is or errors.As later. The convention is to check errors immediately. if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You cannot ignore errors in Go. The compiler rejects code that discards errors without using the blank identifier.
If you need to load plugins from a directory, iterate over the files and call LoadPlugin. Collect the successful plugins and report failures.
func LoadPlugins(dir string) ([]Plugin, error) {
// Read directory entries. Filter for .so files.
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read dir: %w", err)
}
var plugins []Plugin
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".so") {
continue
}
path := filepath.Join(dir, entry.Name())
p, err := LoadPlugin(path)
if err != nil {
// Log the error but continue loading other plugins.
fmt.Fprintf(os.Stderr, "failed to load %s: %v\n", path, err)
continue
}
plugins = append(plugins, p)
}
return plugins, nil
}
The loop filters for .so files. It calls LoadPlugin for each file. If a plugin fails to load, it logs the error and continues. This allows partial success. The host can run with the plugins that loaded correctly. The function returns a slice of plugins. The caller can iterate over the slice and call methods on each plugin.
Pitfalls and gotchas
The biggest trap is symbol collision. The host and plugin share the same address space. If both import a package that registers global state, you get a panic. For example, if both the host and plugin import database/sql and call sql.Register, you get panic: duplicate registration. The compiler won't catch this. You'll see the panic at runtime. Avoid global registrations in plugins. Pass dependencies through the interface instead.
Versioning is another issue. The plugin must be built with the same Go version as the host. Mixing versions can cause subtle ABI mismatches. The compiler gives no warning. You get a segfault or weird behavior. Enforce the Go version in your build pipeline. Document the required version for plugin authors.
The plugin package is Unix-only in most Go versions. Windows support is experimental or missing. If you need cross-platform plugins, consider a different approach. You can use an external process with HTTP or gRPC. This adds latency but provides isolation and portability.
Type assertions are the only safety net. If the plugin exports a symbol with the wrong type, the assertion fails. You get a panic. You can use a type switch to provide better error messages.
switch v := sym.(type) {
case Plugin:
return v, nil
case func() Plugin:
// Some plugins export a factory function.
return v(), nil
default:
return nil, fmt.Errorf("unexpected type %T", sym)
}
This handles both direct implementations and factory functions. It gives a clear error message if the type is wrong. The %T verb prints the dynamic type of the interface value. This helps debug mismatched plugins.
Goroutine leaks happen when the plugin starts a goroutine that never exits. Since the plugin runs in the host process, a leaked goroutine keeps the host alive. Always provide a cancellation mechanism. Pass a context.Context to the plugin if it starts long-running work. The convention is that context.Context always goes as the first parameter, named ctx. Functions that take a context should respect cancellation and deadlines.
When to use plugins
Go plugins are powerful but heavy. They require a build step, shared OS support, and careful version management. Choose the right tool for the job.
Use the plugin package when you need runtime extensibility and can guarantee the plugin runs on the same OS and Go version as the host. Use a configuration file or JSON schema when the extension is just data or simple rules that don't require custom logic. Use an external process with HTTP or gRPC when you need isolation, sandboxing, or the plugin might be written in a different language. Use embedded scripting like goja when users need to write quick logic without compiling code. Use a static build with feature flags when you want a single binary with no external dependencies.
Shared memory means shared fate. If the plugin crashes, the host goes down. Pick the boundary that matches your trust model.