04 - Convertendo para arquitetura hexagonal

Continuação do artigo:
03 - Caso prático usando Golang

Vamos deixar a arquiteturo disso melhor: hexagonal, com métricas Prometheus + OpenTelemetry e com um modelo voltado a categorização de produto (ex.: AUTOPEÇAS, INFORMÁTICA, MATERIAL_CONSTRUÇÃO).

Vou dividir em 3 partes:

  1. Novo modelo em Python (produto → categoria) exportado em ONNX.

  2. API em Go com arquitetura hexagonal usando esse model.onnx.

  3. Dockerfile da API em Go (reaproveitando o esquema do ONNX Runtime).


1. Treino em Python – modelo de categorização de produto

Aqui vou fazer algo didático, mas ERP-friendly:

Você depois pode substituir os dados sintéticos pelos seus vetores reais (ex.: embeddings de descrição + marca + grupo atual).

Estrutura de pastas

ml-onnx-example/
  ml/
    train_product_category.py
    requirements.txt
  model/
    model.onnx            # será gerado aqui

ml/requirements.txt

torch
numpy

ml/train_product_category.py

import os
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim

NUM_FEATURES = 16
NUM_CLASSES = 3

CATEGORY_NAMES = ["AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"]

class ProductCategoryNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc1 = nn.Linear(NUM_FEATURES, 32)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(32, NUM_CLASSES)

    def forward(self, x):
        x = self.fc1(x)
        x = self.relu(x)
        x = self.fc2(x)
        return x  # logits

def generate_synthetic_data(n_samples_per_class: int = 200):
    """
    Gera dados sintéticos só para exemplo.
    Em produção, troque isso pelos seus embeddings reais.
    """

    # Cluster AUTOPECAS
    autopecas = np.random.normal(loc=0.0, scale=0.5, size=(n_samples_per_class, NUM_FEATURES))
    # Cluster INFORMATICA
    informatica = np.random.normal(loc=3.0, scale=0.5, size=(n_samples_per_class, NUM_FEATURES))
    # Cluster MATERIAL_CONSTRUCAO
    matconst = np.random.normal(loc=-3.0, scale=0.5, size=(n_samples_per_class, NUM_FEATURES))

    X = np.vstack([autopecas, informatica, matconst]).astype(np.float32)
    y = np.array(
        [0] * n_samples_per_class +
        [1] * n_samples_per_class +
        [2] * n_samples_per_class,
        dtype=np.int64
    )

    # embaralha
    idx = np.random.permutation(len(X))
    return X[idx], y[idx]

def main():
    X, y = generate_synthetic_data(300)
    X_tensor = torch.from_numpy(X)
    y_tensor = torch.from_numpy(y)

    model = ProductCategoryNet()
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=0.01)

    model.train()
    epochs = 50

    for epoch in range(epochs):
        optimizer.zero_grad()
        outputs = model(X_tensor)
        loss = criterion(outputs, y_tensor)
        loss.backward()
        optimizer.step()

        if (epoch + 1) % 10 == 0:
            _, preds = torch.max(outputs, 1)
            acc = (preds == y_tensor).float().mean().item()
            print(f"Epoch [{epoch+1}/{epochs}] - Loss: {loss.item():.4f} - Acc: {acc:.4f}")

    os.makedirs("model", exist_ok=True)
    model.eval()

    dummy_input = torch.randn(1, NUM_FEATURES)
    onnx_path = os.path.join("model", "model.onnx")

    torch.onnx.export(
        model,
        dummy_input,
        onnx_path,
        input_names=["input"],
        output_names=["output"],
        dynamic_axes={
            "input": {0: "batch_size"},
            "output": {0: "batch_size"},
        },
        opset_version=17,
    )

    print(f"Modelo de categorização de produto salvo em: {onnx_path}")
    print("Categorias:", CATEGORY_NAMES)

if __name__ == "__main__":
    main()

Você pode encaixar isso no mesmo Dockerfile.ml anterior, só trocando o CMD ou mantendo ambos scripts e escolhendo qual rodar.


2. API em Go com arquitetura hexagonal

Estrutura de diretórios da API Go

api-go/
  cmd/
    api/
      main.go
  internal/
    domain/
      category/
        model.go      # entidades + tipos
        ports.go      # interfaces (ports)
    app/
      service.go     # orquestra domínio + portas
    infra/
      onnx/
        predictor.go  # adapter para ONNX Runtime
      observability/
        metrics.go    # Prometheus
        tracing.go    # OpenTelemetry
    http/
      router.go      # monta chi.Router
      handlers.go    # handlers HTTP
  go.mod
  go.sum

api-go/go.mod (exemplo)

Use o módulo que preferir:

module github.com/seuuser/ml-onnx-example/api-go

go 1.22

require (
	github.com/go-chi/chi/v5 v5.0.12
	github.com/prometheus/client_golang v1.19.0
	github.com/yalue/onnxruntime_go v0.3.4
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0
	go.opentelemetry.io/otel v1.27.0
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0
	go.opentelemetry.io/otel/sdk v1.27.0
)

Versões são ilustrativas; você pode ajustar com go get -u.


2.1 Domínio: tipos + port

internal/domain/category/model.go

package category

// ProductCategory representa uma categoria de produto no seu ERP.
type ProductCategory struct {
	Code string `json:"code"`
	Name string `json:"name"`
}

// PredictionInput é a entrada de predição: vetor de features numéricas (embedding).
type PredictionInput struct {
	Features []float32 `json:"features"`
}

// PredictionResult é o resultado de uma predição de categoria.
type PredictionResult struct {
	Category      ProductCategory `json:"category"`
	Probabilities []float32       `json:"probabilities"`
}

internal/domain/category/ports.go

package category

import "context"

// CategoryPredictorPort é a porta de saída do domínio
// usada pelo app/service para pedir uma predição ao modelo.
type CategoryPredictorPort interface {
	PredictCategory(ctx context.Context, input PredictionInput) (PredictionResult, error)
}

2.2 Aplicação: orquestra serviço

internal/app/service.go

package app

import (
	"context"
	"errors"

	"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)

var ErrInvalidFeaturesLen = errors.New("quantidade de features inválida; esperado 16")

const ExpectedFeaturesLen = 16

// CategoryService fica no centro, conhece apenas a porta do predictor.
type CategoryService struct {
	predictor category.CategoryPredictorPort
}

func NewCategoryService(p category.CategoryPredictorPort) *CategoryService {
	return &CategoryService{predictor: p}
}

func (s *CategoryService) PredictProductCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
	if len(input.Features) != ExpectedFeaturesLen {
		return category.PredictionResult{}, ErrInvalidFeaturesLen
	}

	return s.predictor.PredictCategory(ctx, input)
}

2.3 Infra: ONNX adapter

internal/infra/onnx/predictor.go

package onnx

import (
	"context"
	"fmt"
	"log"
	"sync"

	"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"

	ort "github.com/yalue/onnxruntime_go"
)

type OnnxCategoryPredictor struct {
	session       *ort.DynamicSession[float32]
	mu            sync.Mutex
	classNames    []string
	expectedInput int
}

func NewOnnxCategoryPredictor(modelPath string, classNames []string, expectedInput int) (*OnnxCategoryPredictor, error) {
	if len(classNames) == 0 {
		return nil, fmt.Errorf("classNames não pode ser vazio")
	}
	if expectedInput <= 0 {
		return nil, fmt.Errorf("expectedInput inválido")
	}

	// Espera que a env já tenha sido inicializada em main (ort.InitializeEnvironment)
	session, err := ort.NewDynamicSession[float32](modelPath, []string{"input"}, []string{"output"})
	if err != nil {
		return nil, fmt.Errorf("erro criando sessão ONNX: %w", err)
	}

	log.Printf("Sessão ONNX carregada a partir de %s", modelPath)

	return &OnnxCategoryPredictor{
		session:       session,
		classNames:    classNames,
		expectedInput: expectedInput,
	}, nil
}

func (o *OnnxCategoryPredictor) PredictCategory(ctx context.Context, input category.PredictionInput) (category.PredictionResult, error) {
	if len(input.Features) != o.expectedInput {
		return category.PredictionResult{}, fmt.Errorf("esperado %d features, recebido %d", o.expectedInput, len(input.Features))
	}

	// ONNX runtime não é automaticamente threadsafe entre Run() simultâneos, então garantimos exclusão
	o.mu.Lock()
	defer o.mu.Unlock()

	shape := ort.NewShape(1, int64(o.expectedInput))
	tensor, err := ort.NewTensor(shape, input.Features)
	if err != nil {
		return category.PredictionResult{}, fmt.Errorf("erro criando tensor input: %w", err)
	}
	defer tensor.Destroy()

	outputShape := ort.NewShape(1, int64(len(o.classNames)))
	outputTensor, err := ort.NewEmptyTensor[float32](outputShape)
	if err != nil {
		return category.PredictionResult{}, fmt.Errorf("erro criando tensor output: %w", err)
	}
	defer outputTensor.Destroy()

	if err := o.session.Run([]*ort.Tensor[float32]{tensor}, []*ort.Tensor[float32]{outputTensor}); err != nil {
		return category.PredictionResult{}, fmt.Errorf("erro executando sessão: %w", err)
	}

	logits := outputTensor.GetData()
	if len(logits) != len(o.classNames) {
		return category.PredictionResult{}, fmt.Errorf("saida inesperada do modelo")
	}

	probs := softmax(logits)

	maxIdx := 0
	maxVal := probs[0]
	for i := 1; i < len(probs); i++ {
		if probs[i] > maxVal {
			maxVal = probs[i]
			maxIdx = i
		}
	}

	cat := category.ProductCategory{
		Code: o.classNames[maxIdx],
		Name: o.classNames[maxIdx], // aqui você pode mapear para descrição mais amigável
	}

	return category.PredictionResult{
		Category:      cat,
		Probabilities: probs,
	}, nil
}

func softmax(logits []float32) []float32 {
	if len(logits) == 0 {
		return []float32{}
	}
	maxLogit := logits[0]
	for _, v := range logits[1:] {
		if v > maxLogit {
			maxLogit = v
		}
	}

	exps := make([]float64, len(logits))
	var sum float64
	for i, v := range logits {
		ev := expFloat64(float64(v - maxLogit))
		exps[i] = ev
		sum += ev
	}

	out := make([]float32, len(logits))
	if sum == 0 {
		return out
	}
	for i, ev := range exps {
		out[i] = float32(ev / sum)
	}
	return out
}

func expFloat64(x float64) float64 {
	const (
		MinExp = -700.0
		MaxExp = 700.0
	)
	if x < MinExp {
		x = MinExp
	} else if x > MaxExp {
		x = MaxExp
	}
	return float64Exp(x)
}

// wrapper só pra ficar explícito
func float64Exp(x float64) float64 {
	return (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) * (1 / (1 / (1 / (1 / 1)))) // zoeirinha só, pode trocar por math.Exp(x)
}

Aqui, por sanidade, você obviamente troca essa zoeira por math.Exp(x) 😄 – só deixei claro onde entra.


2.4 Observabilidade – Prometheus + OpenTelemetry

internal/infra/observability/metrics.go

package observability

import (
	"net/http"
	"strconv"
	"time"

	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	httpRequestsTotal = prometheus.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "Total de requisições HTTP por rota e método",
		},
		[]string{"path", "method", "status"},
	)

	httpRequestDuration = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "Duração das requisições HTTP",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"path", "method"},
	)
)

func InitMetrics() {
	prometheus.MustRegister(httpRequestsTotal, httpRequestDuration)
}

func MetricsHandler() http.Handler {
	return promhttp.Handler()
}

// Middleware para chi
func MetricsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()

		ww := &statusWriter{ResponseWriter: w, statusCode: http.StatusOK}
		next.ServeHTTP(ww, r)

		duration := time.Since(start).Seconds()
		path := r.URL.Path
		method := r.Method
		status := strconv.Itoa(ww.statusCode)

		httpRequestsTotal.WithLabelValues(path, method, status).Inc()
		httpRequestDuration.WithLabelValues(path, method).Observe(duration)
	})
}

type statusWriter struct {
	http.ResponseWriter
	statusCode int
}

func (w *statusWriter) WriteHeader(code int) {
	w.statusCode = code
	w.ResponseWriter.WriteHeader(code)
}

internal/infra/observability/tracing.go

package observability

import (
	"context"
	"os"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"

	"go.opentelemetry.io/otel/semconv/v1.21.0"
	"google.golang.org/grpc"
)

func InitTracer(ctx context.Context, serviceName string) (func(context.Context) error, error) {
	endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
	if endpoint == "" {
		// se não tiver endpoint, usa no-op (só log local)
		tp := sdktrace.NewTracerProvider(
			sdktrace.WithSampler(sdktrace.AlwaysSample()),
			sdktrace.WithResource(resource.NewWithAttributes(
				semconv.SchemaURL,
				semconv.ServiceNameKey.String(serviceName),
			)),
		)
		otel.SetTracerProvider(tp)
		return tp.Shutdown, nil
	}

	exporter, err := otlptracegrpc.New(
		ctx,
		otlptracegrpc.WithEndpoint(endpoint),
		otlptracegrpc.WithInsecure(),
		otlptracegrpc.WithDialOption(grpc.WithBlock()),
	)
	if err != nil {
		return nil, err
	}

	tp := sdktrace.NewTracerProvider(
		sdktrace.WithBatcher(exporter),
		sdktrace.WithResource(resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceNameKey.String(serviceName),
		)),
	)

	otel.SetTracerProvider(tp)
	return tp.Shutdown, nil
}

2.5 HTTP – router + handlers (chi)

internal/http/handlers.go

package http

import (
	"encoding/json"
	"net/http"

	"github.com/seuuser/ml-onnx-example/api-go/internal/app"
	"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
)

type CategoryHandlers struct {
	svc *app.CategoryService
}

func NewCategoryHandlers(svc *app.CategoryService) *CategoryHandlers {
	return &CategoryHandlers{svc: svc}
}

type predictRequest struct {
	Features []float32 `json:"features"`
}

type predictResponse struct {
	CategoryCode string    `json:"category_code"`
	CategoryName string    `json:"category_name"`
	Probabilities []float32 `json:"probabilities"`
}

func (h *CategoryHandlers) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (h *CategoryHandlers) PredictCategory(w http.ResponseWriter, r *http.Request) {
	var req predictRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "JSON inválido")
		return
	}

	input := category.PredictionInput{
		Features: req.Features,
	}

	result, err := h.svc.PredictProductCategory(r.Context(), input)
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}

	resp := predictResponse{
		CategoryCode: result.Category.Code,
		CategoryName: result.Category.Name,
		Probabilities: result.Probabilities,
	}

	writeJSON(w, http.StatusOK, resp)
}

func writeJSON(w http.ResponseWriter, status int, payload any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(payload)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

internal/http/router.go

package http

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func NewRouter(h *CategoryHandlers) http.Handler {
	r := chi.NewRouter()

	// middlewares padrão
	r.Use(middleware.RequestID)
	r.Use(middleware.RealIP)
	r.Use(middleware.Logger)
	r.Use(middleware.Recoverer)

	// métricas Prometheus
	r.Use(observability.MetricsMiddleware)

	// rotas principais
	r.Get("/healthz", h.Healthz)
	r.Post("/predict", h.PredictCategory)

	// endpoint metrics
	r.Handle("/metrics", observability.MetricsHandler())

	// wrap com otelhttp para tracing
	return otelhttp.NewHandler(r, "product-category-api")
}

2.6 cmd/api/main.go

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"time"

	ort "github.com/yalue/onnxruntime_go"

	"github.com/seuuser/ml-onnx-example/api-go/internal/app"
	"github.com/seuuser/ml-onnx-example/api-go/internal/domain/category"
	apihttp "github.com/seuuser/ml-onnx-example/api-go/internal/http"
	"github.com/seuuser/ml-onnx-example/api-go/internal/infra/observability"
	"github.com/seuuser/ml-onnx-example/api-go/internal/infra/onnx"
)

func main() {
	ctx := context.Background()

	// ONNX Runtime: lib compartilhada (configurada no Docker via ONNXRUNTIME_LIB)
	if libPath := os.Getenv("ONNXRUNTIME_LIB"); libPath != "" {
		ort.SetSharedLibraryPath(libPath)
	}

	if err := ort.InitializeEnvironment(); err != nil {
		log.Fatalf("erro inicializando onnxruntime: %v", err)
	}
	defer func() {
		if err := ort.DestroyEnvironment(); err != nil {
			log.Printf("erro destruindo ambiente onnxruntime: %v", err)
		}
	}()

	// Observabilidade
	observability.InitMetrics()

	shutdownTracer, err := observability.InitTracer(ctx, "product-category-api")
	if err != nil {
		log.Printf("erro inicializando tracer: %v", err)
	} else {
		defer func() {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer cancel()
			if err := shutdownTracer(ctx); err != nil {
				log.Printf("erro encerrando tracer: %v", err)
			}
		}()
	}

	modelPath := os.Getenv("MODEL_PATH")
	if modelPath == "" {
		modelPath = "model/model.onnx"
	}

	classNames := []string{"AUTOPECAS", "INFORMATICA", "MATERIAL_CONSTRUCAO"}
	expectedFeatures := 16

	predictor, err := onnx.NewOnnxCategoryPredictor(modelPath, classNames, expectedFeatures)
	if err != nil {
		log.Fatalf("erro criando predictor ONNX: %v", err)
	}

	svc := app.NewCategoryService(predictor)

	handlers := apihttp.NewCategoryHandlers(svc)
	router := apihttp.NewRouter(handlers)

	addr := ":8000"
	log.Printf("API de categorização de produtos ouvindo em %s", addr)

	if err := http.ListenAndServe(addr, router); err != nil {
		log.Fatalf("erro servidor HTTP: %v", err)
	}
}

3. Dockerfile da API em Go (hexagonal)

Dockerfile.api-go (mesma ideia de antes, só ajustando paths):

FROM golang:1.22-bookworm AS builder

WORKDIR /app

# Copia go.mod/go.sum
COPY api-go/go.mod api-go/go.sum ./api-go/
WORKDIR /app/api-go
RUN go mod download

# Copia código
COPY api-go/ ./ 

# Compila
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o /app/bin/api-go ./cmd/api

# -------------------------------------------------------------------

FROM debian:bookworm-slim

WORKDIR /app

RUN apt-get update && apt-get install -y \
    ca-certificates \
    curl \
    && rm -rf /var/lib/apt/lists/*

ENV ONNX_VERSION=1.20.0
ENV ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so

RUN curl -L "https://github.com/microsoft/onnxruntime/releases/download/v${ONNX_VERSION}/onnxruntime-linux-x64-${ONNX_VERSION}.tgz" \
    -o /tmp/onnxruntime.tgz && \
    mkdir -p /opt/onnxruntime && \
    tar -xzf /tmp/onnxruntime.tgz -C /opt/onnxruntime --strip-components=1 && \
    cp /opt/onnxruntime/lib/libonnxruntime.so /usr/local/lib/ && \
    rm /tmp/onnxruntime.tgz

COPY --from=builder /app/bin/api-go /usr/local/bin/api-go

RUN mkdir -p /app/model
WORKDIR /app

EXPOSE 8000

CMD ["api-go"]

Como isso se encaixa no compose

No docker-compose.yml, você troca o serviço api-go para apontar para esse Dockerfile, e monta ./model:

services:
  trainer:
    build:
      context: .
      dockerfile: Dockerfile.ml
    volumes:
      - ./model:/app/model
    command: ["python", "ml/train_product_category.py"]

  api-go:
    build:
      context: .
      dockerfile: Dockerfile.api-go
    volumes:
      - ./model:/app/model:ro
    environment:
      - ONNXRUNTIME_LIB=/usr/local/lib/libonnxruntime.so
      - MODEL_PATH=/app/model/model.onnx
      # - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317  # se quiser mandar tracing
    ports:
      - "8000:8000"

Ver depois:
05 - Entender o que até agora foi feito