Arquitetura Hexagonal em Zig: Ports, Adapters e Testes sem Framework

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:

  1. Domínio: regras e tipos centrais. Exemplo: criar pedido, calcular total, validar limite, registrar pagamento.
  2. Ports: contratos que o domínio espera. Exemplo: PedidoRepository, Clock, Mailer, PaymentGateway.
  3. 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.Allocator em 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.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.