El modelo multi-tenant
Cómo White Label soporta múltiples marcas sobre la misma base de código: un binario por tenant, inyección de dependencias con FX y una jerarquía clara para modelar las diferencias entre marcas.
El problema
White Label corre para múltiples marcas: Jumbo, Disco, Prezunic, Santa Isabel, entre otras. Cada una tiene sus particularidades: distintos proveedores, distintos flujos de autenticación, distintas reglas de validación, distintos mercados.
La pregunta de diseño es cómo modelar esas diferencias sin que el código se convierta en una acumulación de condicionales por marca, y sin duplicar lógica entre servicios que hacen esencialmente lo mismo.
La respuesta está en cómo se construye el sistema al arrancar, no en cómo se comporta mientras corre.
Features, no tenants
Las reglas y flags se definen en términos de la feature, no del
tenant que la usa. No existe un SignInPrezunic ni un flag
IsBrasil. Existe UseOAuth, que hoy activa Prezunic y mañana
puede activar cualquier otra marca que adopte el mismo mecanismo
sin solamente activando ese flag para el tenant que lo solicite.
Este principio requiere un esfuerzo consciente al diseñar. La tentación natural es crear una regla con el nombre del tenant y justificarla como “esto existe porque así lo hace fulano”. Eso es exactamente lo que hay que evitar. Antes de definir una regla hay que identificar la feature subyacente, nombrarla en esos términos e inyectarla como comportamiento configurable. El tenant que la usa hoy es un detalle de configuración, no parte del diseño.
Un deployment, un tenant
Cada instancia del sistema corre configurada para una sola marca. El tenant se especifica al arrancar el servicio como argumento:
main server --tenant=br-prezunic
main server --tenant=co-jumbo
main server --tenant=ar-disco
Ese valor se inyecta como el primer nodo del grafo de FX. A partir de ahí, todos los módulos que necesiten comportarse distinto según la marca reciben ese valor como dependencia y resuelven su configuración en tiempo de construcción, antes de que el servidor empiece a recibir tráfico.
No hay detección de tenant en runtime. No hay un mapa de tenants que se consulta en cada request. Las decisiones están tomadas desde el momento en que el proceso arranca.
tenant, err := cmd.Flags().GetString("tenant")
if err != nil {
log.Println("🚨 Error reading tenant argument:", err)
os.Exit(1)
}
app := fx.New(
// Tenant
fx.Provide(func() (config.TenantValue, error) {
return config.NewTenant(tenant)
}),
// Modules
common.Module,
platform.Module,
health.Module,
auth.Module,
)
app.Run()
Los tres niveles de variación
Las diferencias entre tenants se modelan con tres mecanismos, en orden de complejidad creciente. El criterio para elegir cuál usar es simple: empezar por el más simple y subir de nivel solo cuando la complejidad lo justifica.
Nivel 1 — Flag
Un flag es un booleano que activa o desactiva una feature o comportamiento. Cuando el flag está activo, se ejecuta el comportamiento asociado. Cuando no lo está, se ejecuta el comportamiento por defecto, o simplemente no se hace nada.
UseOAuth es el ejemplo más claro: todos los tenants necesitan
autenticación, pero el flujo varía. El comportamiento por defecto
es autenticación via VTEX. Prezunic activa UseOAuth y el flujo
cambia por completo a OAuth. El mismo caso de uso, dos caminos
de ejecución, un solo flag que decide cuál tomar.
Tip
Revisar el micro de core auth para cómo cambia el flujo de auth según el tenant
Nivel 2 — Function type
Una function type modela una regla que siempre aplica pero que cada tenant implementa de forma distinta. Se define como un tipo de función con una firma clara (entrada y salida esperadas), se proveen implementaciones comunes reutilizables y las específicas por tenant, y cada tenant recibe la suya al construir el core.
Ejemplo function type definición
type UsernameSanitizer func(string) string
type UsernameValidator func(string) error
// Implementaciones comunes
func EmptySanitizer(username string) string {
return username
}
func EmailValidator(email string) error {
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
if !regexp.MustCompile(pattern).MatchString(email) {
return errors.New("invalid email")
}
return nil
}
Ejemplo function type inyección
sanitizer := rule.EmptySanitizer
validator := rule.EmailValidator
switch tenant {
case config.BR_PREZUNIC:
sanitizer = prezunic.CpfSanitizer
validator = prezunic.CpfValidator
}
La mayoría de tenants usa email como identificador y recibe
EmailValidator. Prezunic usa CPF y recibe CpfValidator.
El caso de uso que invoca el validador no sabe cuál está usando,
y no necesita saberlo.
Las implementaciones específicas de un tenant viven en una
carpeta con su nombre dentro del core, por ejemplo core/prezunic/.
Eso las mantiene aisladas y fáciles de localizar.
Ejemplo core configurando reglas
func NewAuthLogic(
tenant config.TenantValue,
cfg *config.AppConfig,
// Más dependencias
...
) *AuthLogic {
// Reglas comunes
withUsername := vtex.WithEmail
useOAuth := false
sanitizer := rule.EmptySanitizer
validator := rule.EmailValidator
var idTokenDecoder rule.IdTokenDecoder = nil
var userInfoDecoder rule.UserInfoDecoder = nil
var usernameExtractor rule.UsernameExtractor = rule.EmailExtractor
// Modificamos comportamiento por defecto según el tenant
switch tenant {
case config.BR_PREZUNIC:
withUsername = vtex.WithDocument
useOAuth = true
sanitizer = prezunic.CpfSanitizer
validator = prezunic.CpfValidator
idTokenDecoder = prezunic.IdTokenDecoder
userInfoDecoder = prezunic.UserInfoDecoder
usernameExtractor = prezunic.UsernameExtractor
}
return &AuthLogic{
// Flags
UseOAuth: useOAuth,
// Reglas function type
usernameSanitizer: sanitizer,
usernameValidator: validator,
idTokenDecoder: idTokenDecoder,
userInfoDecoder: userInfoDecoder,
usernameExtractor: usernameExtractor,
// Filter for vtex search (esto es un function type)
withUsername: withUsername,
// Configs
defaultPostalCode: cfg.Vtex.DefaultPostalCode,
// Más dependencias
...
}
}
Ejemplo caso de uso usango reglas
func (c *AuthLogic) SignIn(
ctx context.Context,
username string,
password string,
) (res *auth.SignInResponse, err error) {
username = c.usernameSanitizer(username)
if err := c.usernameValidator(username); err != nil {
return nil, err
}
if c.UseOAuth {
res, err = c.SignInOAuth(ctx, username, password)
} else {
res, err = c.SignInVtex(ctx, username, password)
}
if err != nil {
c.publishSignInEvent(ctx, username)
}
return res, err
}
Este caso de uso integra los dos primeros niveles en acción.
usernameSanitizer y usernameValidator son function types:
siempre se invocan, pero cada tenant tiene su implementación.
Prezunic sanitiza y valida un CPF, el resto sanitiza y valida
un email. El caso de uso no distingue entre uno y otro.
UseOAuth es un flag: bifurca el flujo completo hacia OAuth o
hacia VTEX según el tenant. Dos caminos de ejecución distintos,
resueltos en una sola línea.
Finalmente, publishSignInEvent se ejecuta sin condicionales.
Publicar el evento de sign in es un comportamiento transversal
a todos los tenants: no depende de reglas ni de flags, aplica
siempre.
Nivel 3 — Interface
Una interface se usa cuando la regla tiene dependencias de plataforma distintas según el tenant, haciendo que una simple función no alcance. Es el nivel más complejo y debe usarse solo cuando los dos anteriores no son suficientes.
SellerValidator es el ejemplo canónico. Validar si un seller
puede atender una dirección es una regla que todos los tenants
necesitan, pero la forma de resolverla varía estructuralmente:
algunos tenants la resuelven consultando regiones en VTEX,
otros necesitan además un CMS para obtener las tiendas del país
antes de correr una simulación de orden. No es solo una función
diferente: son dependencias diferentes.
Interface definition
type SellerValidator interface {
ValidateSellers(
ctx context.Context,
req *v1.ValidateSellersRequest,
) (*v1.ValidateSellersResponse, error)
}
Note
v1.ValidateSellersRequest y v1.ValidateSellersResponse son
structs generados por Protobuf. No son DTOs ni entidades de dominio
propias: son el modelo. Las reglas de negocio, los casos de uso y
los servicios todos hablan el mismo lenguaje porque todos usan
los mismos tipos generados a partir del .proto. Esto es el
principio de Protobuf como modelo único en acción.
Hay dos implementaciones concretas. SellerValidatorRegion
resuelve la validación consultando regiones directamente en VTEX
y solo necesita el cliente de VTEX. SellerValidatorSimulation
necesita además un cliente de CMS para obtener las tiendas del
país antes de correr una simulación de orden en VTEX.
La selección de cuál implementación usar se resuelve en el módulo
FX del SellerValidator, no en el core. El módulo recibe el
tenant y las dependencias disponibles, construye la implementación
correcta y la provee como SellerValidator al grafo.
var Module = fx.Module(
"sellervalidator",
fx.Provide(
func(
tenant config.TenantValue,
cms *cms.CmsClient,
vtex *vtex.VtexClient,
) SellerValidator {
switch tenant {
case config.AR_JUMBO, config.AR_DISCO:
return NewSellerValidatorSimulation(cms, vtex)
case config.CO_JUMBO, config.BR_PREZUNIC:
return NewSellerValidatorRegion(vtex)
default:
return nil
}
},
),
)
El core recibe un SellerValidator y llama a ValidateSellers
sin saber qué implementación está usando. La complejidad de
seleccionar y construir la implementación correcta queda
encapsulada en el módulo FX, que es el lugar natural para ese
tipo de decisiones de composición.
Este es el patrón a seguir cada vez que una regla requiere dependencias propias: definir una interface mínima con el comportamiento necesario, crear una implementación por variante, y resolver la selección en el módulo FX correspondiente.
Principios
Un binario, un tenant. Cada instancia del sistema corre configurada para una sola marca. No hay detección de tenant en runtime ni mapas de configuración que se consultan en cada request. Las decisiones están tomadas desde el momento en que el proceso arranca.
Features, no tenants. Las reglas y flags se nombran en
términos de la feature que modelan, no del tenant que las usa.
UseOAuth, no IsPrezunic. Una regla bien nombrada puede ser
adoptada por cualquier tenant futuro sin tocar su definición.
La variación se modela explícitamente. Las diferencias entre tenants no se esconden detrás de condicionales dispersos. Se declaran en el constructor del core o en el módulo FX que corresponda, en un solo lugar, antes de que el servidor empiece a correr.
Complejidad mínima necesaria. El orden de preferencia es flag → function type → interface. Cada nivel agrega expresividad pero también complejidad. Se sube de nivel solo cuando el nivel anterior no alcanza para modelar la diferencia.
Tip
Para entender cómo los microservicios y dominios se comunican entre sí a través de eventos, continuá con Comunicación orientada a eventos