A plugin architecture lets a program be extended without rewriting the whole program.
A plugin architecture lets a program be extended without rewriting the whole program.
The main program provides a stable core. Plugins add behavior around that core.
Examples:
text editor plugins
database extensions
compiler passes
game engine modules
image processing filters
web server middleware
command-line subcommandsThe basic idea is simple:
core program + plugin interface + plugin implementationsThe hard part is designing the boundary between the core program and each plugin.
A Simple Example
Suppose we are writing a small text processing program.
We want different operations:
uppercase
lowercase
trim spaces
count wordsOne option is to hard-code every operation into the main program.
A plugin-style design separates the operation from the core loop.
const std = @import("std");
const Plugin = struct {
name: []const u8,
run: *const fn (input: []const u8, writer: *std.Io.Writer) anyerror!void,
};This Plugin type stores two things:
a name
a function pointerThe function pointer is the plugin behavior.
Function Pointers as Plugins
Here are two plugin functions:
const std = @import("std");
fn uppercase(input: []const u8, writer: *std.Io.Writer) !void {
for (input) |byte| {
if (byte >= 'a' and byte <= 'z') {
try writer.writeByte(byte - 32);
} else {
try writer.writeByte(byte);
}
}
}
fn repeat(input: []const u8, writer: *std.Io.Writer) !void {
try writer.print("{s}{s}", .{ input, input });
}Each function has the same shape:
fn (input: []const u8, writer: *std.Io.Writer) anyerror!voidThat shared shape is the plugin interface.
Now we can create plugin values:
const plugins = [_]Plugin{
.{
.name = "uppercase",
.run = uppercase,
},
.{
.name = "repeat",
.run = repeat,
},
};Each plugin has a different implementation, but the core program can call all of them the same way.
Calling a Plugin
The main program can search by name and run the matching plugin.
const std = @import("std");
const Plugin = struct {
name: []const u8,
run: *const fn (input: []const u8, writer: *std.Io.Writer) anyerror!void,
};
fn uppercase(input: []const u8, writer: *std.Io.Writer) !void {
for (input) |byte| {
if (byte >= 'a' and byte <= 'z') {
try writer.writeByte(byte - 32);
} else {
try writer.writeByte(byte);
}
}
}
fn repeat(input: []const u8, writer: *std.Io.Writer) !void {
try writer.print("{s}{s}", .{ input, input });
}
const plugins = [_]Plugin{
.{ .name = "uppercase", .run = uppercase },
.{ .name = "repeat", .run = repeat },
};
fn findPlugin(name: []const u8) ?Plugin {
for (plugins) |plugin| {
if (std.mem.eql(u8, plugin.name, name)) {
return plugin;
}
}
return null;
}
pub fn main() !void {
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
const plugin = findPlugin("uppercase") orelse {
try stdout.writeAll("plugin not found\n");
return;
};
try plugin.run("hello zig\n", stdout);
try stdout.flush();
}Output:
HELLO ZIGThis is a small plugin architecture. It has:
a shared interface
multiple implementations
a registry
a lookup function
a core program that calls plugins through the interfaceStatic Plugins
The example above uses static plugins.
That means the plugins are compiled into the program.
source code -> compiler -> one executableStatic plugins are simple and safe.
They work well when:
you know all plugins at build time
plugins are part of your source tree
you want easy cross-compilation
you want fewer deployment problemsThe downside is that adding a new plugin requires rebuilding the program.
For many Zig programs, that is acceptable.
Dynamic Plugins
A dynamic plugin is loaded while the program runs.
Common forms include:
shared libraries
dynamic libraries
runtime-loaded modules
external processes
script files
WebAssembly modulesFor example, on Unix-like systems, a plugin may be a .so file. On Windows, it may be a .dll file. On macOS, it may be a .dylib file.
Dynamic plugins let users add behavior without rebuilding the main program.
But they create more design problems:
ABI stability
version compatibility
memory ownership
allocator boundaries
error handling
thread safety
security
deploymentDynamic plugins are powerful, but they require stricter boundaries.
The Plugin Boundary
The most important part of any plugin system is the boundary.
The boundary answers questions like:
What functions must a plugin provide?
Who owns memory?
Who frees memory?
What errors can cross the boundary?
Which version of the interface is being used?
Can plugins call back into the host?
Can plugins run concurrently?A weak boundary makes the whole system fragile.
A strong boundary makes plugins boring to write and boring to load. That is good.
Avoid Passing Complex Zig Types Across Dynamic Boundaries
Inside one Zig program, rich Zig types are fine:
[]const u8
std.ArrayList(u8)
error unions
tagged unions
generic structsAcross a dynamic library boundary, be more careful.
The plugin and host may be compiled with different compiler versions, different build options, or different standard library versions.
For dynamic plugin boundaries, prefer simple ABI-friendly types:
extern struct
pointers
lengths
integers
C-compatible function pointers
explicit status codesFor example:
const PluginInput = extern struct {
ptr: [*]const u8,
len: usize,
};
const PluginOutput = extern struct {
ptr: [*]u8,
len: usize,
};This is less convenient than slices, but it is clearer across an ABI boundary.
A Zig slice is a pointer and length conceptually, but using an explicit extern struct makes the ABI contract visible.
Version Your Plugin Interface
A plugin interface should have a version number.
const PLUGIN_API_VERSION: u32 = 1;The plugin can export a function returning its API version.
export fn pluginApiVersion() u32 {
return PLUGIN_API_VERSION;
}The host checks this before calling the plugin.
if (plugin_version != PLUGIN_API_VERSION) {
// reject plugin
}This prevents the host from accidentally calling a plugin built for a different interface.
Without version checks, plugin failures can become mysterious crashes.
A C-Compatible Plugin Interface
A dynamic plugin interface often looks like a C API.
Example:
const PluginInfo = extern struct {
api_version: u32,
name_ptr: [*]const u8,
name_len: usize,
};The plugin exposes a function:
export fn pluginInfo() PluginInfo {
const name = "uppercase";
return PluginInfo{
.api_version = 1,
.name_ptr = name.ptr,
.name_len = name.len,
};
}The host reads the plugin information and checks the version.
This is plain, but plain is good for ABI design.
Memory Ownership
Memory ownership must be explicit.
Bad plugin boundary:
Plugin returns a pointer.
Host guesses who frees it.Good plugin boundary:
Host provides output buffer.
Plugin writes into the buffer.
Plugin returns how many bytes were written.Example shape:
const PluginInput = extern struct {
ptr: [*]const u8,
len: usize,
};
const PluginBuffer = extern struct {
ptr: [*]u8,
len: usize,
};
const PluginResult = extern struct {
status: i32,
written: usize,
};The host owns the output buffer. The plugin only writes into it.
That avoids allocator mismatch problems.
Error Handling Across Plugin Boundaries
Inside normal Zig code, error unions are excellent.
Across a dynamic plugin ABI, explicit status codes are often safer.
const Status = enum(i32) {
ok = 0,
output_too_small = 1,
invalid_input = 2,
internal_error = 3,
};A plugin function can return:
const PluginResult = extern struct {
status: i32,
written: usize,
};The host translates status codes back into Zig errors if it wants.
fn statusToError(status: i32) !void {
return switch (status) {
0 => {},
1 => error.OutputTooSmall,
2 => error.InvalidInput,
else => error.PluginInternalError,
};
}This keeps the ABI simple while preserving Zig-style error handling inside the host.
Plugins as External Processes
Dynamic libraries are not the only plugin architecture.
Another design is process-based plugins.
The host starts a separate executable and communicates through:
stdin and stdout
JSON
MessagePack
HTTP
Unix sockets
pipesThis is slower than direct function calls, but it gives better isolation.
If a process plugin crashes, the host can survive.
Process plugins are useful when:
plugins may be untrusted
plugins may be written in different languages
plugin crashes should not crash the host
you want easier sandboxing
you want simpler deployment boundariesFor many tools, process plugins are the best first design.
Plugins as WebAssembly Modules
WebAssembly is another useful plugin format.
A host can load a .wasm module and expose a limited API to it.
This can give:
portable plugins
sandboxing
language independence
controlled host callsThe tradeoff is extra runtime complexity. You need a WebAssembly runtime, an ABI, and a memory model between the host and plugin.
For serious plugin systems, WebAssembly can be cleaner than raw dynamic libraries.
Static Registry Pattern
For simple projects, use a static registry.
const Plugin = struct {
name: []const u8,
run: *const fn ([]const u8, *std.Io.Writer) anyerror!void,
};
const registry = [_]Plugin{
.{ .name = "uppercase", .run = uppercase },
.{ .name = "repeat", .run = repeat },
};This pattern is easy to test.
It is easy to cross-compile.
It does not require dynamic loading.
It works well for:
CLI subcommands
compiler passes
game engine systems
data importers
formatters
small build toolsUse this until you truly need runtime loading.
Command Pattern
A plugin is often just a command.
const Command = struct {
name: []const u8,
help: []const u8,
run: *const fn (allocator: std.mem.Allocator, args: []const []const u8) anyerror!void,
};This is common in CLI tools.
Example registry:
const commands = [_]Command{
.{
.name = "init",
.help = "Create a new project",
.run = cmdInit,
},
.{
.name = "build",
.help = "Build the project",
.run = cmdBuild,
},
.{
.name = "test",
.help = "Run tests",
.run = cmdTest,
},
};The main program parses the first argument, finds the command, and calls it.
That is a plugin architecture, even though everything is statically linked.
Host API Pattern
Sometimes plugins need to call back into the host.
For example, a plugin may need to:
log messages
read files
allocate memory
emit diagnostics
register new commands
query configurationYou can pass a host API into the plugin.
const HostApi = struct {
log: *const fn (message: []const u8) void,
readFile: *const fn (path: []const u8) anyerror![]u8,
};Then plugin functions receive it:
const PluginRun = *const fn (
host: *const HostApi,
input: []const u8,
writer: *std.Io.Writer,
) anyerror!void;This makes host access explicit.
A plugin cannot magically reach into the whole program. It can only use what the host gives it.
Capability-Based Design
A good plugin system limits what plugins can do.
Instead of giving every plugin full access to everything, pass only the capabilities it needs.
Example:
const LoggingApi = struct {
log: *const fn (message: []const u8) void,
};
const FileApi = struct {
readFile: *const fn (path: []const u8) anyerror![]u8,
};
const NetworkApi = struct {
request: *const fn (url: []const u8) anyerror![]u8,
};A formatter plugin may only need logging.
A package plugin may need file access.
A remote plugin may need network access.
This design makes security and testing easier.
Thread Safety
A plugin architecture must define its threading rules.
Questions:
Can plugins run in parallel?
Can the same plugin instance be called from multiple threads?
Can plugins store global state?
Does the host provide synchronization?If you do not define this, plugin authors will guess.
For simple systems, start with this rule:
The host calls one plugin at a time.
Plugins must not store mutable global state.Then relax the rule only when needed.
For concurrent systems, document the contract clearly.
Testing Plugins
A good plugin interface is easy to test.
If a plugin is just a function pointer with input and output, a test can call it directly.
test "uppercase plugin" {
var buffer: [128]u8 = undefined;
var stream = std.Io.Writer.fixed(&buffer);
try uppercase("abc", &stream);
const output = stream.buffered();
try std.testing.expectEqualStrings("ABC", output);
}The exact writer helper may differ depending on the Zig version and standard library API, but the principle remains the same: give the plugin controlled input and capture its output.
Keep the Interface Small
A plugin interface should start small.
Bad first version:
50 functions
deep object graph
shared mutable state
implicit global registry
multiple allocation strategiesGood first version:
name
version
run function
input
output
diagnosticsSmall interfaces are easier to keep stable.
You can always add features later.
Removing features is harder.
Designing for Failure
Plugins fail.
They may:
return invalid data
take too long
run out of memory
crash
use the wrong API version
produce malformed outputA robust host treats plugin failure as normal.
For static plugins, failure is usually a Zig error.
For dynamic libraries, failure may be a bad symbol, wrong version, or crash.
For process plugins, failure may be a non-zero exit code or invalid output.
For WebAssembly plugins, failure may be a trap.
Design the host so one plugin failure does not destroy the whole program unless that is the intended policy.
Choosing a Plugin Style
Use static plugins when you want simplicity.
Use dynamic libraries when you need fast in-process extension and you control the ABI carefully.
Use process plugins when you want isolation and language independence.
Use WebAssembly when you want portable sandboxed plugins with a controlled host API.
For most Zig programs, static plugins are the best first step. They use the language directly, avoid ABI problems, and work well with Zig’s compile-time model.
The Main Idea
A plugin architecture is not mainly about loading code dynamically.
It is about drawing a clean boundary.
The host owns the core system.
The plugin implements a narrow interface.
The boundary defines names, versions, memory ownership, errors, threading, and permissions.
In Zig, start with simple static plugins using structs and function pointers. Move to dynamic libraries, process plugins, or WebAssembly only when the extra complexity is justified.