Arquitetura de plugins é uma decisão de produto, não apenas uma técnica de linguagem. Ela aparece quando uma ferramenta precisa crescer sem virar um binário inchado, quando clientes querem extensões próprias, quando integrações mudam mais rápido que o núcleo, ou quando um time precisa separar módulos experimentais de partes estáveis. Em Zig, esse desenho pode ser feito de duas formas principais: plugins resolvidos em tempo de compilação com comptime, ou plugins carregados em runtime via bibliotecas dinâmicas e ABI C.
A primeira abordagem é simples, rápida e segura: o compilador valida tudo antes do deploy. A segunda é mais flexível, mas traz custos reais de ABI, versionamento, memória, segurança e observabilidade. Este guia mostra como escolher entre elas e como desenhar um sistema de plugins em Zig que continue previsível em produção.
Se você está criando uma CLI extensível, leia também Zig para aplicações de linha de comando. Para empacotar e distribuir binários, combine este material com GitHub Actions para releases multiplataforma e supply chain em Zig. Para a parte de integração C, veja interoperabilidade Zig-C.
Quando usar plugins em Zig
Um sistema de plugins vale a pena quando o núcleo do programa é estável, mas algumas capacidades precisam variar. Bons exemplos:
- formatos de importação e exportação;
- conectores para APIs externas;
- regras de validação específicas por cliente;
- comandos extras em uma CLI interna;
- transformações em um pipeline de dados;
- backends de armazenamento, cache ou fila;
- integrações experimentais que não devem travar o produto principal.
Não use plugins para esconder arquitetura confusa. Se toda mudança vira plugin porque o núcleo não tem fronteiras claras, o problema não é extensibilidade: é modelagem. Um bom plugin recebe entrada pequena, devolve saída bem definida e não depende de estado global secreto.
Escolha 1: plugins com comptime
A forma mais idiomática de começar em Zig é definir uma interface validada em tempo de compilação. O plugin é um tipo Zig comum que implementa algumas declarações obrigatórias. O registro de plugins usa inline for e @hasDecl para validar o contrato.
const std = @import("std");
fn Plugin(comptime T: type) type {
comptime {
if (!@hasDecl(T, "name")) @compileError("plugin precisa declarar name");
if (!@hasDecl(T, "run")) @compileError("plugin precisa declarar run");
}
return struct {
pub fn run(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
return try T.run(allocator, input);
}
};
}
const UppercasePlugin = struct {
pub const name = "uppercase";
pub fn run(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
const out = try allocator.alloc(u8, input.len);
for (input, 0..) |c, i| out[i] = std.ascii.toUpper(c);
return out;
}
};
const uppercase = Plugin(UppercasePlugin);
Essa abordagem é excelente para plugins internos: comandos de uma CLI, transformadores de build, backends opcionais compilados junto, regras por produto ou módulos ativados por configuração. O binário final continua único e o compilador impede que um plugin incompleto passe despercebido.
Registro estático de plugins
Com comptime, também dá para criar um registro estático sem reflection em runtime:
fn Registry(comptime plugins: []const type) type {
return struct {
pub fn runByName(
allocator: std.mem.Allocator,
name: []const u8,
input: []const u8,
) !?[]u8 {
inline for (plugins) |P| {
if (std.mem.eql(u8, P.name, name)) {
return try Plugin(P).run(allocator, input);
}
}
return null;
}
};
}
const AppPlugins = Registry(&.{ UppercasePlugin });
O custo é que você precisa recompilar para adicionar um plugin. Em muitos produtos isso é uma vantagem: a release é auditável, os plugins entram pelo mesmo code review e o usuário final não carrega código arbitrário.
Escolha 2: plugins dinâmicos via ABI C
Quando o usuário precisa instalar extensões depois do deploy, a conversa muda. O caminho mais estável é tratar o plugin como uma shared library (.so, .dylib, .dll) que expõe uma pequena ABI C. O host carrega a biblioteca com std.DynLib, procura uma função conhecida e recebe uma tabela de funções.
const std = @import("std");
const PluginApi = extern struct {
abi_version: u32,
name: [*:0]const u8,
run: *const fn ([*]const u8, usize, *usize) callconv(.C) ?[*]u8,
free: *const fn ([*]u8, usize) callconv(.C) void,
};
const EXPECTED_ABI_VERSION = 1;
pub fn loadPlugin(path: []const u8) !PluginApi {
var lib = try std.DynLib.open(path);
errdefer lib.close();
const get_api = lib.lookup(
*const fn () callconv(.C) *const PluginApi,
"zig_plugin_get_api",
) orelse return error.PluginApiMissing;
const api = get_api().*;
if (api.abi_version != EXPECTED_ABI_VERSION) return error.PluginAbiMismatch;
return api;
}
A ABI deve ser pequena e conservadora. Evite expor structs Zig complexas, slices, allocators internos ou tipos que possam mudar entre versões do compilador. Use ponteiros, tamanhos explícitos, inteiros, códigos de erro e funções de liberação claras.
Exemplo de plugin exportado
Um plugin pode exportar uma função que retorna a tabela:
const std = @import("std");
fn run(input_ptr: [*]const u8, input_len: usize, out_len: *usize) callconv(.C) ?[*]u8 {
const input = input_ptr[0..input_len];
const allocator = std.heap.c_allocator;
const out = allocator.alloc(u8, input.len) catch return null;
for (input, 0..) |c, i| out[i] = std.ascii.toUpper(c);
out_len.* = out.len;
return out.ptr;
}
fn free(ptr: [*]u8, len: usize) callconv(.C) void {
std.heap.c_allocator.free(ptr[0..len]);
}
const api = PluginApi{
.abi_version = 1,
.name = "uppercase",
.run = run,
.free = free,
};
export fn zig_plugin_get_api() callconv(.C) *const PluginApi {
return &api;
}
O detalhe mais importante é ownership. Se o plugin aloca memória, o plugin deve liberar essa memória. O host não deve chamar seu próprio allocator sobre memória de outro módulo. Esse erro é uma fonte clássica de corrupção, vazamento e bugs intermitentes.
Segurança: plugin não é sandbox
Carregar uma shared library é executar código nativo dentro do seu processo. Ela pode ler memória, abrir arquivos, fazer rede, travar o processo e vazar segredo. Portanto, plugin dinâmico não é sandbox. Se você precisa executar código não confiável, use isolamento de processo, container, WASM com runtime controlado ou uma API externa.
Para plugins de terceiros confiáveis, ainda aplique limites:
- diretório de plugins com permissões controladas;
- assinatura, checksum ou lista de hashes permitidos;
abi_versionobrigatório;- timeout no nível do processo chamador quando possível;
- logs por plugin;
- feature flags para desativar plugin problemático;
- documentação clara sobre API estável e API experimental.
Para temas de segurança operacional, veja configuração segura em Zig e supply chain em Zig.
Hot reload: cuidado com estado e ponteiros
Hot reload parece atraente: recompilar um plugin e trocar a shared library sem reiniciar o host. Na prática, é difícil fazer com segurança. Antes de descarregar uma biblioteca, você precisa garantir que nenhum ponteiro, thread, callback ou objeto vindo dela ainda está em uso.
Um caminho mais seguro é tratar reload como ciclo de vida explícito:
- marque o plugin como drenando;
- pare de enviar novas chamadas;
- espere chamadas ativas terminarem;
- chame
shutdownse a ABI tiver essa função; - descarregue a biblioteca;
- carregue a nova versão;
- rode um health check do plugin antes de receber tráfego.
Para ferramentas locais e CLIs, reiniciar o processo costuma ser mais simples e mais confiável. Para serviços long-running, considere processo separado por plugin. O custo de IPC pode ser menor que o custo operacional de hot reload nativo quebrado.
Testes para arquitetura de plugins
Teste plugins em três camadas:
- teste unitário do plugin como módulo Zig comum;
- teste de contrato da interface esperada pelo host;
- teste de integração carregando a shared library real.
Também vale ter um plugin propositalmente incompatível para validar erros de ABI. O host deve falhar de forma explícita: PluginAbiMismatch, PluginApiMissing, PluginLoadFailed, PluginReturnedInvalidData. Nunca transforme erro de plugin em panic genérico se o produto pode continuar sem aquele módulo.
Quando escolher cada abordagem
Use comptime quando:
- os plugins são internos ao repositório;
- você quer binário único;
- code review e CI devem cobrir todos os módulos;
- performance e simplicidade são mais importantes que instalação dinâmica.
Use shared libraries quando:
- o usuário precisa instalar extensões depois da release;
- integrações mudam independentemente do host;
- existe uma ABI pequena e estável;
- você aceita o custo de versionamento, segurança e suporte.
Use processo separado quando:
- o plugin não é totalmente confiável;
- um crash de plugin não pode derrubar o host;
- você precisa aplicar limites de CPU, memória, filesystem ou rede;
- a interface pode ser HTTP, JSON-RPC, stdio ou fila.
Essa terceira opção combina bem com servidores MCP em Zig e arquitetura event-driven, porque troca a complexidade da ABI nativa por um protocolo explícito.
Checklist de produção
Antes de publicar um sistema de plugins em Zig, confirme:
- o contrato do plugin cabe em uma página;
- existe versão de ABI;
- ownership de memória está documentado;
- o host valida nome, versão e capacidades;
- falhas de plugin não derrubam o processo principal sem necessidade;
- logs identificam qual plugin falhou;
- CI compila pelo menos um plugin real;
- há teste de incompatibilidade de ABI;
- a documentação diz como empacotar, assinar e atualizar plugins;
- plugins não confiáveis rodam fora do processo.
Conclusão
Zig é uma boa linguagem para arquitetura de plugins porque deixa as fronteiras explícitas. Com comptime, você ganha extensibilidade validada pelo compilador e sem custo de runtime. Com shared libraries, você ganha instalação dinâmica, mas precisa tratar ABI, memória, segurança e versionamento como partes centrais do design.
A regra prática é começar pelo modelo mais simples. Se os plugins são seus, use comptime. Se precisam ser instalados por terceiros, mantenha uma ABI C pequena. Se não são confiáveis, tire do processo. Essa clareza combina com a filosofia de Zig: menos mágica, mais contratos explícitos.