Fundamentos / Arquitectura White Label
Arquitectura

Arquitectura White Label

Cómo está diseñado técnicamente el sistema: las cuatro capas, los principios que guían cada decisión y por qué la arquitectura toma la forma que toma.

Por qué esta arquitectura

White Label es una capa de orquestación. Esa naturaleza define cómo diseñamos el sistema: la complejidad está en coordinar sistemas externos, gestionar las diferencias entre proveedores y soportar múltiples marcas sobre la misma base de código. La arquitectura está pensada para resolver exactamente eso, de la forma más directa posible.

Dominios

Un dominio es un área funcional cohesiva del sistema con responsabilidades claras y bien acotadas. Identity & Access (IAM), Cart, Loyalty, Customer Profile o Customer Address son ejemplos de dominios. Cada uno tiene sus propios datos, su propia lógica y su propio ciclo de vida.

El modelo ideal es un microservicio por dominio. En la práctica, durante procesos de migración o cuando un servicio creció sin un modelo claro, un microservicio puede contener más de un dominio. Eso no es un error irrecuperable, pero sí es deuda técnica: una señal de que en algún punto hay que hacer el corte.

Cuando un microservicio trabaja con más de un dominio, cada uno debe tener sus propias capas completamente aisladas. No hay llamadas entre dominios a nivel de código dentro del mismo microservicio. Si un dominio necesita reaccionar a algo que ocurrió en otro, lo debe hacer a través de eventos.

Esta restricción tiene un objetivo concreto: que dos dominios que conviven en el mismo microservicio por razones de transición puedan separarse en microservicios independientes en el futuro sin que esa separación implique un rediseño. Si el acoplamiento es solo por eventos desde el primer día, el corte es quirúrgico.

Tip

Para profundizar en cómo funciona la comunicación entre dominios a través de eventos, ver Comunicación orientada a eventos.

Las cuatro capas

Cada dominio está organizado en cuatro capas. Las primeras tres son propias del dominio. La cuarta es transversal a todos.

Service

Es el punto de entrada al dominio. Implementa los servicios gRPC generados por Protobuf y los consumers de mensajería que escuchan eventos entrantes. Su trabajo es estrecho: validar que el request tiene la forma correcta, transformar datos si es necesario antes de pasarlos al core, y construir la respuesta final a partir de lo que el core devuelve.

Important

El service no toma decisiones de negocio.

Los consumers de mensajería forman parte de esta capa pero viven en su propia carpeta para diferenciarlos de los handlers gRPC. La relación con el core es exactamente la misma: reciben un mensaje, llaman al core, no tienen lógica propia.

Core

Es donde vive la lógica de orquestación del dominio. El core es una struct que recibe como dependencias todo lo que necesita: abstracciones de datos, clientes de plataforma, flags de comportamiento y reglas de negocio. Cada caso de uso es un método de esa struct, en un archivo separado.

Los casos de uso pueden recibir y devolver los mensajes generados por Protobuf directamente. Cuando un mismo caso de uso sirve a varios RPCs distintos, puede recibir parámetros explícitos en lugar de un request gRPC directamente.

El core no sabe nada de HTTP ni gRPC. No sabe si quien lo llama es un handler REST o un consumer de cola. Eso es responsabilidad del service.

Note

Algunos casos de uso pueden retornar errores de gRPC directamente cuando se estime conveniente; ej: cuando se quiere cambiar el status code de una respuesta de error. Uno de los principios de la arquitectura es no ocultar detalles de implementación, contrario a lo que se hace con DDD (Domain Driven Design).

Ejemplo de caso de uso orquestando lógica diferente según reglas configuradas en el core.
func (c *AuthLogic) SignIn(
    ctx context.Context,
    username string,
    password string,
) (*auth.SignInResponse, error) {
    username = c.usernameSanitizer(username)
    if err := c.usernameValidator(username); err != nil {
        return nil, err
    }
    if c.UseOAuth {
        return c.SignInOAuth(ctx, username, password)
    }
    return c.SignInVtex(ctx, username, password)
}

El ejemplo muestra dos conceptos clave del core. Primero, usernameSanitizer y usernameValidator son reglas de negocio inyectadas como dependencias. No están hardcodeadas: cada tenant puede tener su propia implementación. Un tenant que usa email como identificador recibe un validador de email. Uno que usa documento de identidad recibe un validador de CPF (Caso Brasil). El caso de uso no sabe cuál está usando, solo las invoca.

Segundo, UseOAuth es un flag: un booleano que activa o desactiva un comportamiento completo según el tenant. En lugar de condicionales dispersos por el código, el flag se resuelve una sola vez al construir el core y determina qué rama de lógica se ejecuta.

Flags y reglas son los dos mecanismos principales para modelar las diferencias entre tenants dentro del core. Cómo se definen e inyectan en detalle se cubre en El modelo multi-tenant.

Data

Contiene las abstracciones sobre los sistemas de almacenamiento que usa el dominio: MongoDB, Redis, PostgreSQL. Cada abstracción agrupa las operaciones relacionadas con una colección o un tipo de storage, con nombres que reflejan la infraestructura que utilizan: cart_collection, session_storage, identity_collection.

Esa elección es intencional. Saber qué base de datos se está usando para cada operación no es un detalle a ocultar: es información relevante para razonar sobre rendimiento, consistencia y operación. Preferimos claridad sobre abstracción.

Los producers de eventos también viven en esta capa. Un producer es una abstracción mínima sobre NATS: recibe un evento del dominio, lo serializa como CloudEvent y lo publica. Sin lógica, sin validación. El core decide cuándo publicar y qué publicar, el producer solo se encarga de enviarlo.

Ejemplo método Publish de un producer
func (p *AddressDefaultSetProducer) Publish(
    ctx context.Context,
    ev *event.AddressDefaultSet,
) error {
    cEvent := ce.NewEvent()
    cEvent.SetID(ev.ID)
    cEvent.SetType(event.AddressDefaultSetEvent)
    cEvent.SetSource(event.CUSTOMER_SOURCE)

    if err := cEvent.SetData(ce.ApplicationJSON, ev); err != nil {
        return err
    }

    return p.producer.Publish(ctx, &cEvent)
}

Tip

Para profundizar en cómo se crean producers y consumers ver Comunicación orientada a eventos.

Platform

Contiene todo lo que es externo al dominio pero necesario para que funcione: clientes crudos de base de datos (mongo.Client, redis.Client), clientes a APIs externas (VTEX, Cognito, CMS), configuración, observabilidad y el servidor gRPC.

Platform no pertenece a ningún dominio. Vive al mismo nivel que los dominios dentro del microservicio y es compartida por todos ellos. Si el core de dos dominios distintos necesita hablar con VTEX, ambos usan el mismo cliente definido en Platform.

El contrato: API-first con Protocol Buffers

Todo microservicio empieza por el contrato. Antes de escribir una línea de lógica, definimos la API usando Protocol Buffers. El .proto es la fuente de verdad del servicio.

service AuthService {
  rpc SignIn(SignInRequest) returns (SignInResponse) {
    option (google.api.http) = {
      post: "/v1/auth/sign-in"
      body: "*"
    };
  }
}

Usando buf, esa definición genera automáticamente:

  • La interfaz gRPC que el microservicio debe implementar.
  • El gateway HTTP que expone la misma API como REST, usando la anotación google.api.http definida en el .proto.
  • La documentación Swagger del endpoint.

Una sola definición, tres outputs. El código generado vive en gen/ dentro del repositorio y nunca se edita a mano.

Protobuf como modelo único

Los structs generados por Protobuf son el modelo del dominio. El core opera directamente sobre esos mensajes: recibe requests y devuelve responses generados a partir del .proto, sin una capa de entidades propias en el medio.

Esto elimina una categoría entera de código: no hay mapeos entre entidades de dominio y DTOs, no hay transformaciones intermedias, no hay riesgo de que dos representaciones del mismo dato diverjan. El contrato es el modelo.

Composición con FX

La inyección de dependencias se maneja con Uber FX. Cada capa define un módulo FX que declara qué provee al resto del sistema. El dominio compone las capas, y el servidor compone los dominios.

Ejemplo módulo de dominio
var Module = fx.Module(
    "auth",
    data.Module,
    core.Module,
    service.Module,
)
Ejemplo servidor cargando modulos de dominio y plataforma
app := fx.New(
    fx.Provide(func() (config.TenantValue, error) {
        return config.NewTenant(tenant)
    }),
    common.Module,
    platform.Module,
    health.Module,
    auth.Module,
)

El estilo es deliberadamente similar al sistema de módulos de NestJS: cada pieza declara sus dependencias, FX construye el grafo y resuelve el orden de inicialización automáticamente.

El tenant se inyecta como el primer valor del grafo. A partir de ese valor, cada módulo que necesite comportarse distinto según la marca puede hacerlo en tiempo de construcción, antes de que el servidor empiece a recibir tráfico. No hay condicionales en runtime dispersos por el código: las diferencias entre tenants se resuelven una sola vez al arrancar.

Estructura de directorios

Todos los microservicios deben seguir la misma estructura. Eso no es burocracia: es lo que permite que cualquier engineer pueda orientarse en un servicio que nunca vio en pocos minutos.

cmd/
  server/
    main.go Inicializa cobra para el manejo de comandos y parámetros
    server.go fx.New(), compone todos los módulos
gen/
  <proto-grpc>/ Código generado por buf, nunca editar a mano
internal/ (modules/ durante la migración en curso)
  common/ Errores y utilidades compartidas entre dominios
  health/ Healthcheck, dominio especial de infraestructura
  platform/ Clientes e infraestructura transversal
    config/
    mongodb/
    redis/
    vtex/
    nats/
  <domain>/
    core/
      core.go Struct principal con dependencias, flags y rules
      <usecase>.go Un archivo por caso de uso
      rule/ Function types para reglas de negocio comunes
      <tenant>/ Implementaciones específicas por tenant
      module.go
    data/
      <store>.go Abstracciones sobre DBs con nombres de infra
      producer/ Publicación de eventos a NATS
      module.go
    service/
      <service>.go Handlers gRPC/HTTP
      errors.go
      module.go
    consumer/ Handlers de mensajes entrantes
      module.go
    event/ Definición de eventos que emite o recibe el dominio
    module.go Modulo de dominio, compone data + core + service + consumer

Principios

Estos son los criterios que guían las decisiones de diseño. No son reglas absolutas, pero sí el punto de partida para cualquier discusión técnica.

El contrato primero. Todo microservicio empieza por el .proto. Si el contrato no está definido, el feature no empieza.

El modelo vive en el contrato. Los structs generados por Protobuf son el modelo del dominio. Agregar entidades propias requiere justificación explícita.

Claridad sobre abstracción. Preferimos nombres que muestran lo que hacen (session_storage, cart_collection) sobre nombres que lo ocultan. Saber qué infraestructura estás usando es una ventaja, no un problema.

Interfaces mínimas. Solo cuando hay dos o más implementaciones reales de un mismo comportamiento. El orden de preferencia para modelar variaciones entre tenants es: flag booleano → function type → interface.

Diseñado para evolucionar. Una regla puede pasar de flag a function type a interface sin costo operacional alto. El sistema debe facilitar el refactor, no resistirlo.

Dominios desacoplados. Los dominios no se llaman entre sí a nivel de código. El acoplamiento ocurre solo a través de eventos, lo que facilita separar dominios en microservicios independientes cuando sea necesario.


Tip

Para entender cómo el sistema soporta múltiples marcas sobre esta misma base, continua con El modelo multi-tenant.