---
title: "Arquitetura Hexagonal em Zig: Ports, Adapters e Testes sem Framework"
url: "https://ziglang.com.br/artigos/zig-arquitetura-hexagonal-ports-adapters/"
markdown_url: "https://ziglang.com.br/artigos/zig-arquitetura-hexagonal-ports-adapters.MD"
description: "Como aplicar arquitetura hexagonal em Zig com ports explícitos, adapters pequenos, allocators injetados, testes rápidos e fronteiras claras para APIs e CLIs."
date: "2026-06-01"
author: ""
---

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

Como aplicar arquitetura hexagonal em Zig com ports explícitos, adapters pequenos, allocators injetados, testes rápidos e fronteiras claras para APIs e CLIs.


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](/artigos/zig-api-rest-completa/), [OpenAPI em Zig](/artigos/zig-openapi-contratos-json-clientes/), [Dependency Injection em Zig](/padroes/dependency-injection/), [Type Erasure](/padroes/type-erasure/) e [Clean Code em Zig](/artigos/zig-clean-code/).

## 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:

```text
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:

```zig
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:

```zig
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:

```zig
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:

```zig
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:

```zig
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:

```zig
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.

```zig
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 <a href="https://golang.com.br/tutoriais/go-clean-architecture/" target="_blank" rel="noopener" onclick="umami.track('portfolio-site-click', { destination: 'golang.com.br' })">Clean Architecture em Go</a>. 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.
