Arquitetura hexagonal em Zig não precisa virar uma pilha de abstrações importada de Java ou TypeScript. A ideia útil é simples: o domínio da aplicação fica no centro, e tudo que conversa com o mundo externo entra por adaptadores pequenos. HTTP, banco de dados, arquivos, fila, relógio, variáveis de ambiente e logs são detalhes substituíveis. O código que decide regra de negócio não deveria depender diretamente de std.http.Server, SQLite, Postgres, Redis ou do formato exato de uma requisição JSON.
Zig combina bem com esse desenho porque a linguagem já favorece dependências explícitas. Você passa std.mem.Allocator, writers, readers, clients e structs como parâmetros. Não há container mágico de injeção de dependência nem reflexão escondida. Se uma função precisa salvar um pedido, receber um relógio e escrever log, isso aparece na assinatura ou no tipo que agrupa essas dependências.
Este guia mostra uma forma prática de usar ports and adapters em Zig para APIs, CLIs e workers pequenos. Ele complementa API REST em Zig, OpenAPI em Zig, Dependency Injection em Zig, Type Erasure e Clean Code em Zig.
O que é arquitetura hexagonal na prática
A arquitetura hexagonal, também chamada de ports and adapters, separa três preocupações:
- Domínio: regras e tipos centrais. Exemplo: criar pedido, calcular total, validar limite, registrar pagamento.
- Ports: contratos que o domínio espera. Exemplo:
PedidoRepository,Clock,Mailer,PaymentGateway. - Adapters: implementações concretas dos ports. Exemplo: SQLite, Postgres, arquivo JSON, API HTTP externa, mock de teste.
Em Zig, um port raramente precisa ser uma hierarquia formal. Muitas vezes ele é apenas um conjunto de funções que uma struct precisa oferecer, verificado por comptime, ou uma pequena vtable quando você realmente precisa trocar implementação em runtime.
O ponto não é desenhar hexágonos em todos os projetos. O ponto é evitar que uma regra importante fique presa dentro de um handler HTTP enorme.
Estrutura de pastas simples
Uma API pequena pode começar assim:
src/
├── main.zig
├── app.zig
├── domain/
│ ├── pedido.zig
│ └── pedido_service.zig
├── ports/
│ ├── pedido_repository.zig
│ └── clock.zig
├── adapters/
│ ├── sqlite_pedido_repository.zig
│ ├── memory_pedido_repository.zig
│ └── system_clock.zig
└── http/
├── router.zig
└── pedido_handler.zig
main.zig monta o processo: allocator, configuração, adapter real e servidor. http/ traduz requisições para comandos da aplicação. domain/ decide regras. adapters/ fala com dependências externas. Essa separação parece burocrática, mas ajuda quando a API cresce de três rotas para trinta.
Comece pelo domínio
Domínio deve ser fácil de testar sem subir servidor, banco ou variável de ambiente. Um tipo simples pode ficar assim:
const std = @import("std");
pub const PedidoError = error{
Vazio,
ValorInvalido,
};
pub const Item = struct {
sku: []const u8,
quantidade: u32,
preco_centavos: u64,
};
pub const Pedido = struct {
id: []const u8,
itens: []const Item,
pub fn totalCentavos(self: Pedido) PedidoError!u64 {
if (self.itens.len == 0) return error.Vazio;
var total: u64 = 0;
for (self.itens) |item| {
if (item.quantidade == 0 or item.preco_centavos == 0) {
return error.ValorInvalido;
}
total += item.preco_centavos * item.quantidade;
}
return total;
}
};
test "calcula total do pedido" {
const itens = [_]Item{
.{ .sku = "livro", .quantidade = 2, .preco_centavos = 3900 },
.{ .sku = "frete", .quantidade = 1, .preco_centavos = 1200 },
};
const pedido = Pedido{ .id = "p1", .itens = &itens };
try std.testing.expectEqual(@as(u64, 9000), try pedido.totalCentavos());
}
Esse arquivo não conhece HTTP, JSON, SQL nem stdout. Se ele quebra, o problema é regra de negócio, não infraestrutura.
Ports com tipos genéricos em comptime
A forma mais idiomática de começar em Zig é passar dependências como tipos concretos e deixar o compilador validar se os métodos existem. Exemplo:
const Pedido = @import("pedido.zig").Pedido;
pub fn criarPedido(
allocator: std.mem.Allocator,
repo: anytype,
clock: anytype,
itens: []const Pedido.Item,
) !Pedido {
const id = try std.fmt.allocPrint(allocator, "pedido-{d}", .{clock.nowMillis()});
errdefer allocator.free(id);
const pedido = Pedido{ .id = id, .itens = itens };
_ = try pedido.totalCentavos();
try repo.salvar(pedido);
return pedido;
}
Esse repo precisa ter salvar(pedido). O clock precisa ter nowMillis(). Se o adapter não oferece esses métodos, o erro aparece em compilação. Para muitos serviços internos, isso é suficiente e mais simples do que vtables.
Adapter em memória para testes
Um adapter de teste pode ser pequeno e direto:
const std = @import("std");
const Pedido = @import("../domain/pedido.zig").Pedido;
pub const MemoryPedidoRepository = struct {
pedidos: std.ArrayList(Pedido),
pub fn init(allocator: std.mem.Allocator) MemoryPedidoRepository {
return .{ .pedidos = std.ArrayList(Pedido).init(allocator) };
}
pub fn deinit(self: *MemoryPedidoRepository) void {
self.pedidos.deinit();
}
pub fn salvar(self: *MemoryPedidoRepository, pedido: Pedido) !void {
try self.pedidos.append(pedido);
}
};
pub const FixedClock = struct {
value: i64,
pub fn nowMillis(self: FixedClock) i64 {
return self.value;
}
};
Agora o caso de uso pode ser testado sem banco:
test "criar pedido salva no repositório" {
const allocator = std.testing.allocator;
var repo = MemoryPedidoRepository.init(allocator);
defer repo.deinit();
const clock = FixedClock{ .value = 1717200000000 };
const itens = [_]Pedido.Item{
.{ .sku = "a", .quantidade = 1, .preco_centavos = 1000 },
};
const pedido = try criarPedido(allocator, &repo, clock, &itens);
defer allocator.free(pedido.id);
try std.testing.expectEqual(@as(usize, 1), repo.pedidos.items.len);
try std.testing.expectEqualStrings("pedido-1717200000000", pedido.id);
}
Esse é o maior ganho da arquitetura hexagonal em Zig: testes rápidos, determinísticos e sem ambiente externo.
Quando usar vtable e type erasure
anytype resolve troca em tempo de compilação. Use vtable quando você precisa escolher adapter em runtime, por exemplo --storage=sqlite ou --storage=memory no mesmo binário.
Uma interface pequena pode ser modelada assim:
pub const PedidoRepository = struct {
ptr: *anyopaque,
salvarFn: *const fn (*anyopaque, Pedido) anyerror!void,
pub fn salvar(self: PedidoRepository, pedido: Pedido) !void {
return self.salvarFn(self.ptr, pedido);
}
};
O adapter concreto fornece uma função ponte:
fn salvarSQLite(ptr: *anyopaque, pedido: Pedido) !void {
const repo: *SQLitePedidoRepository = @ptrCast(@alignCast(ptr));
try repo.salvar(pedido);
}
Não comece por aqui se não precisa. Vtables exigem mais cuidado com lifetimes, alinhamento e ponteiros. Em Zig, a regra prática é: comptime primeiro, vtable só quando a troca em runtime for real.
Handler HTTP como adapter de entrada
O handler HTTP não deveria conter a regra de criação do pedido. Ele traduz fronteiras:
- lê body com limite;
- faz parse JSON;
- converte DTO para tipos do domínio;
- chama caso de uso;
- traduz erro para status HTTP;
- escreve resposta.
Isso deixa claro que HTTP é apenas um adapter de entrada. A mesma regra pode ser chamada por CLI, fila, teste de carga ou worker agendado.
pub fn handleCriarPedido(ctx: *AppContext, req: *Request, res: *Response) !void {
const dto = try parsePedidoJson(ctx.allocator, req.body);
defer dto.deinit(ctx.allocator);
const pedido = criarPedido(ctx.allocator, ctx.repo, ctx.clock, dto.itens) catch |err| {
return escreverErro(res, err);
};
defer ctx.allocator.free(pedido.id);
try escreverPedidoJson(res.writer(), pedido);
}
Compare isso com um handler que abre conexão SQL, calcula total, decide regra, formata JSON e loga incidente no mesmo bloco. A segunda versão funciona no dia um, mas cobra juros quando a aplicação precisa mudar.
Como isso conversa com Clean Architecture
Clean Architecture, arquitetura hexagonal e onion architecture compartilham a mesma intuição: dependências apontam para dentro. O centro não conhece detalhes externos. Em Go, esse desenho costuma aparecer com interfaces pequenas; um bom paralelo está no guia de Clean Architecture em Go. Em Zig, a diferença é que você pode obter parte do mesmo desacoplamento com tipos explícitos, comptime, allocators e testes sem precisar reproduzir toda a cerimônia de uma linguagem orientada a objetos.
Evite transformar a arquitetura em teatro. Se o projeto tem uma CLI de 300 linhas, talvez main.zig, config.zig e storage.zig bastem. Se a regra de negócio já está sendo chamada por HTTP, jobs, testes e scripts, a separação em ports e adapters começa a pagar.
Checklist prático
Antes de chamar um projeto Zig de hexagonal, verifique:
- regras centrais rodam em testes sem HTTP e sem banco;
- adapters concretos ficam nas bordas;
- funções recebem
std.mem.Allocatorem vez de escolher allocator global escondido; - erros do domínio são diferentes de erros de transporte;
- handlers convertem entrada/saída, mas não concentram regra;
- dependências externas podem ser substituídas por adapters em memória;
- OpenAPI, README ou testes de contrato descrevem a fronteira pública;
- logs e métricas aparecem nas bordas, não espalhados por toda regra.
Erros comuns
O erro mais comum é criar pastas demais antes de existir complexidade real. Zig recompensa simplicidade: comece com poucos módulos, extraia ports quando a dependência externa começar a atrapalhar teste ou evolução.
Outro erro é esconder tudo atrás de ponteiros genéricos. Se o adapter é conhecido em compilação, anytype e structs concretas geram código claro e rápido. Reserve type erasure para plugins, seleção runtime ou bibliotecas que precisam de API estável.
Também evite copiar nomes de arquitetura sem traduzir para o problema. UseCase, Interactor, Presenter, Gateway e Repository podem ser úteis, mas não são obrigatórios. Em Zig, nomes como pedido_service.zig, sqlite_pedido_repository.zig e pedido_handler.zig costumam ser mais legíveis.
Conclusão
Arquitetura hexagonal em Zig é menos sobre camadas formais e mais sobre fronteiras honestas. O domínio deve ser pequeno, testável e independente. Ports devem ser contratos mínimos. Adapters devem lidar com detalhes do mundo externo sem contaminar regra de negócio.
Quando aplicada com moderação, essa abordagem combina muito bem com a filosofia de Zig: dependências explícitas, custo visível, testes rápidos e menos mágica. O resultado é um backend, worker ou CLI que continua simples no começo, mas não vira um bloco rígido quando precisa crescer.