---
id: "ADR-004"
title: "Cliente REST externo con resty en la capa Platform"
date: "2026-04-14"
status: "active"
superseded_by: ""
tags: ["platform", "http-client", "resty", "otel", "rest", "external-api", "fx"]
summary: "En el contexto de los microservicios White Label que deben integrarse con APIs
REST externas (VTEX, proveedores de recomendaciones, CMS, etc.), frente al problema de
que cada integración podía implementarse de forma distinta sin convenciones compartidas
de instrumentación ni manejo de errores, decidimos usar resty.dev/v3 con un
http.RoundTripper OTel inyectado por FX y una estructura de archivos fija por cliente,
para lograr que cada integración sea instrumentada, observable y estructuralmente
predecible, aceptando que el patrón agrega más archivos por cliente comparado con
una implementación monolítica."
---

## Contexto

La capa Platform concentra todos los clientes hacia sistemas externos. Sin una convención
compartida, cada integración nueva puede reinventar cómo construir el cliente HTTP,
cómo instrumentarlo con OpenTelemetry, cómo manejar errores del proveedor vs errores
de red, y cómo validar la configuración al startup.

Un punto crítico es la visibilidad al startup: si una dependencia externa no está
configurada, el microservicio debe indicarlo claramente en los logs al iniciar, ya sea
para señalar que no estará disponible (dependencia opcional) o para fallar con razón
explícita (dependencia requerida).

## Alternativas consideradas

### Opción A — `net/http` directo
Construir los clientes usando únicamente la librería estándar de Go.

**Por qué se descartó:** Requiere boilerplate significativo para gestionar base URL,
headers comunes, timeouts y deserialización de respuestas. La instrumentación OTel
debe implementarse manualmente en cada cliente. Para el volumen de integraciones
externas de la plataforma, la homogeneidad que ofrece una librería dedicada supera
el beneficio de no agregar dependencias.

### Opción B — `resty.dev/v3` sin estructura de archivos definida
Usar resty libremente, sin convenciones sobre cómo organizar el código ni cómo manejar
errores.

**Por qué se descartó:** Sin una estructura predecible, dos clientes en la misma
plataforma pueden verse completamente distintos. Un agente o engineer que abre un
cliente nuevo no sabe dónde buscar la definición de recursos, ni dónde agrega un
método nuevo. La inconsistencia es el problema que se quiere evitar.

### Opción elegida — `resty.dev/v3` con estructura fija y transport OTel
Usar resty con una estructura de archivos estandarizada, transport OTel inyectado por FX,
y convenciones claras para recursos, DTOs y manejo de errores. Construido a partir de las
implementaciones reales ya validadas en producción.

## Decisión

We will implement every external REST API client in the platform layer following the
structure and conventions defined below.

### Ubicación y estructura de archivos

Cada cliente vive en su propio package bajo `internal/platform/<nombre-del-servicio>/`:

```
internal/platform/<servicio>/
  client.go      ← struct Client + constructor NewClient
  module.go      ← fx.Module con fx.Provide(NewClient)
  resources.go   ← recursos del dominio del proveedor (Customer, Address, Promotion…)
  <método>.go    ← un archivo por endpoint consumido
```

### Struct y constructor

El struct se llama siempre `Client` — el package namespaca la referencia
(`vtex.Client`, `recommendations_api.Client`). Nunca se usa el nombre del servicio
como prefijo del struct.

El constructor recibe siempre `*config.AppConfig` y `http.RoundTripper`:

```go
type Client struct {
    http *resty.Client
}

func NewClient(cfg *config.AppConfig, transport http.RoundTripper) *Client {
    // validar configuración crítica...

    _http := resty.New().
        SetTransport(transport).
        SetBaseURL(cfg.ExternalService.URL).
        SetHeader("Accept", "application/json").
        SetHeader("x-api-key", cfg.ExternalService.APIKey)

    if cfg.ExternalService.Timeout > 0 {
        _http.SetTimeout(time.Duration(cfg.ExternalService.Timeout) * time.Second)
    }

    return &Client{http: _http}
}
```

Si el cliente necesita acceder a valores de configuración en cada request (ej: country code,
tenant-specific params), guarda el sub-config en el struct:

```go
type Client struct {
    cfg  config.ExternalServiceConfig
    http *resty.Client
}
```

### Transport OTel

El `http.RoundTripper` que se inyecta es el transport instrumentado con OpenTelemetry
que provee el shared package vía `otel.Module`. FX lo inyecta automáticamente.
**Nunca se crea un `http.Client` propio dentro del cliente.**

### Validación de configuración al startup

Hay dos casos según si la dependencia es requerida u opcional para el servicio:

**Dependencia opcional** — el servicio puede funcionar sin ella:
```go
func NewClient(cfg *config.AppConfig, transport http.RoundTripper) *Client {
    if cfg.RecommendationsProvider.URL == "" {
        fmt.Println("⚠️  RECOMMENDATIONS_API_URL is empty, recommendations client will not be available")
        return nil
    }
    // ...
}
```

**Dependencia requerida** — el servicio no puede funcionar sin ella:
```go
func NewClient(cfg *config.AppConfig, transport http.RoundTripper) (*Client, error) {
    if cfg.Vtex.APIURL == "" {
        fmt.Println("❌  VTEX_API_URL is required but not set")
        return nil, fmt.Errorf("VTEX_API_URL is required")
    }
    // ...
}
```

El log con `fmt.Println` es intencional: aparece al startup antes de que el logger
estructurado esté completamente inicializado, permitiendo identificar qué dependencias
están disponibles o por qué el microservicio no levantó.

### Module FX

```go
var Module = fx.Module(
    "nombre_del_servicio",
    fx.Provide(NewClient),
)
```

Se registra en `internal/platform/module.go`.

### Archivos de método

Cada endpoint consumido tiene su propio archivo. Puede contener tipos de request/response
específicos de ese endpoint:

```go
// get_customer.go
type GetCustomerResponse struct {
    ID    string `json:"id"`
    Email string `json:"email"`
}

func (c *Client) GetCustomer(ctx context.Context, customerID string) (*GetCustomerResponse, error) {
    var response GetCustomerResponse
    var failure ProviderError

    res, err := c.http.R().SetContext(ctx).
        SetResult(&response).
        SetError(&failure).
        Get("/customers/" + customerID)

    if err != nil {
        slog.ErrorContext(ctx, "❌ Network error calling GetCustomer", slog.Any("error", err))
        return nil, err
    }

    if res.IsError() {
        slog.ErrorContext(ctx, "❌ Failed to get customer", slog.String("error", failure.Message))
        return nil, fmt.Errorf("failed to get customer: %s", failure.Message)
    }

    return &response, nil
}
```

Si la API no devuelve errores con una estructura conocida, se omite `SetError` y el
manejo de error se adapta al contrato real de la API.

### resources.go

Contiene los recursos del dominio del proveedor externo — los tipos que representan
entidades, no DTOs de transferencia. Se nombran por lo que son, sin sufijo:

```go
// resources.go
type Customer struct { ... }
type Address  struct { ... }
type Region   struct { ... }

type ProviderError struct {
    Message string `json:"message"`
    Code    string `json:"code"`
}
```

Los DTOs específicos de un endpoint (request bodies, response wrappers) van en el
archivo del método correspondiente.

### Parámetros opcionales: functional options

Cuando un endpoint acepta parámetros opcionales, se usa el patrón functional options:

```go
type GetRegionsOption func(*getRegionsRequest)

func WithPostalCode(code string) GetRegionsOption {
    return func(r *getRegionsRequest) { r.postalCode = code }
}

func WithGeoCoordinates(coords string) GetRegionsOption {
    return func(r *getRegionsRequest) { r.geoCoordinates = coords }
}

func (c *Client) GetRegions(ctx context.Context, opts ...GetRegionsOption) ([]Region, error) {
    req := &getRegionsRequest{}
    for _, opt := range opts { opt(req) }
    // ...
}
```

Este patrón es el **preferido** cuando hay dos o más parámetros opcionales. Para un
único parámetro requerido, un argumento directo es suficiente.

## Consecuencias

**Positivas:**
- Todos los clientes HTTP externos son automáticamente instrumentados con OTel sin
  código adicional en cada cliente.
- La estructura predecible permite a cualquier engineer (humano o agente) saber dónde
  agregar un nuevo método o dónde encontrar la definición de un recurso.
- La visibilidad al startup sobre dependencias faltantes reduce el tiempo de diagnóstico
  en despliegues fallidos.
- El patrón functional options facilita evolucionar endpoints con nuevos parámetros
  sin romper las firmas existentes.

**Negativas:**
- `resty.dev/v3` es una dependencia externa en todos los microservicios que integren
  APIs REST.
- Clientes simples (un solo endpoint) tienen más archivos de los estrictamente necesarios.

**Riesgos y mitigaciones:**
- **Core que llama a un cliente `nil` sin chequear** → El spec de cada feature debe
  indicar explícitamente si la dependencia es opcional o requerida. El Core que recibe
  un cliente opcional debe validar `if client == nil` antes de usarlo.
- **Desacoplamiento del contrato de error de la API** → Si la API cambia su estructura
  de error, el tipo en `resources.go` queda desactualizado silenciosamente. La cobertura
  de tests de integración es la mitigación principal.
- **Método que no loguea errores** → Usar siempre `slog.ErrorContext` para mantener
  trazabilidad. El linter puede ayudar, pero la revisión de código es la barrera final.

## ADRs relacionados

- ADR-000: Arquitectura en cuatro capas (los clientes REST viven en Platform, la capa
  más baja; el Core los recibe como dependencias)
- ADR-002: Modelo multi-tenant (si un cliente necesita comportamiento diferente por
  tenant, se usa el orden flag → function type → interface, nunca `if tenant == "x"`)
